judgekit 0.2.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.
judgekit/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """judgekit: audit an LLM judge before you trust it."""
2
+
3
+ from judgekit.audit import AuditReport, run_audit
4
+ from judgekit.io import dump_verdicts, load_verdicts
5
+ from judgekit.result import ProbeResult, SkippedProbe
6
+ from judgekit.schema import BinaryVerdict, GradedVerdict, PairwiseVerdict
7
+
8
+ __version__ = "0.2.0"
9
+
10
+ __all__ = [
11
+ "AuditReport",
12
+ "BinaryVerdict",
13
+ "GradedVerdict",
14
+ "PairwiseVerdict",
15
+ "ProbeResult",
16
+ "SkippedProbe",
17
+ "__version__",
18
+ "dump_verdicts",
19
+ "load_verdicts",
20
+ "run_audit",
21
+ ]
judgekit/agreement.py ADDED
@@ -0,0 +1,178 @@
1
+ """Agreement between a judge and a reference rater.
2
+
3
+ Implemented from scratch on plain float arithmetic; the test suite cross-checks
4
+ every function against an independent reference (scikit-learn, scipy, and the
5
+ krippendorff package). Nothing here calls into BLAS, so results are identical
6
+ across platforms — reductions use numpy's fixed pairwise summation and the
7
+ small confusion/coincidence matrices are accumulated in Python loops.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections import Counter
13
+ from collections.abc import Sequence
14
+ from typing import Literal
15
+
16
+ import numpy as np
17
+ from numpy.typing import NDArray
18
+
19
+ Weights = Literal["linear", "quadratic"] | None
20
+ FloatArray = Sequence[float] | NDArray[np.float64]
21
+
22
+
23
+ def _as_pair(
24
+ a: FloatArray, b: FloatArray
25
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
26
+ x = np.asarray(a, dtype=np.float64)
27
+ y = np.asarray(b, dtype=np.float64)
28
+ if x.shape != y.shape:
29
+ raise ValueError(f"length mismatch: {x.shape} vs {y.shape}")
30
+ if x.size == 0:
31
+ raise ValueError("no ratings")
32
+ return x, y
33
+
34
+
35
+ def confusion(
36
+ a: FloatArray, b: FloatArray, labels: FloatArray
37
+ ) -> NDArray[np.float64]:
38
+ """Count matrix with rows indexed by ``a`` and columns by ``b``."""
39
+ x, y = _as_pair(a, b)
40
+ index = {label: i for i, label in enumerate(labels)}
41
+ matrix = np.zeros((len(labels), len(labels)), dtype=np.float64)
42
+ for u, v in zip(x.tolist(), y.tolist(), strict=True):
43
+ if u not in index or v not in index:
44
+ raise ValueError(f"rating pair ({u}, {v}) outside labels {list(labels)}")
45
+ matrix[index[u], index[v]] += 1.0
46
+ return matrix
47
+
48
+
49
+ def cohen_kappa(
50
+ a: FloatArray,
51
+ b: FloatArray,
52
+ labels: FloatArray | None = None,
53
+ weights: Weights = None,
54
+ ) -> float:
55
+ """Cohen's kappa, optionally weighted for ordinal scales.
56
+
57
+ Disagreement weights follow scikit-learn: distances are between label
58
+ *positions* in ``labels``, so pass the full scale (for instance
59
+ ``[1, 2, 3, 4, 5]``) rather than letting a category that nobody used
60
+ silently shrink the scale. ``quadratic`` is the usual choice for graded
61
+ scores. Returns nan when raters use a single label, where chance-corrected
62
+ agreement is undefined.
63
+ """
64
+ x, y = _as_pair(a, b)
65
+ if labels is None:
66
+ labels = sorted(set(x.tolist()) | set(y.tolist()))
67
+ counts = confusion(x, y, labels)
68
+ n = counts.sum()
69
+ observed = counts / n
70
+ row = observed.sum(axis=1)
71
+ col = observed.sum(axis=0)
72
+ expected = np.outer(row, col)
73
+
74
+ k = len(labels)
75
+ positions = np.arange(k, dtype=np.float64)
76
+ delta = np.abs(positions[:, None] - positions[None, :])
77
+ if weights is None:
78
+ w = (delta > 0).astype(np.float64)
79
+ elif weights == "linear":
80
+ w = delta
81
+ elif weights == "quadratic":
82
+ w = delta**2
83
+ else:
84
+ raise ValueError(f"unknown weights {weights!r}")
85
+
86
+ expected_disagreement = float((w * expected).sum())
87
+ if expected_disagreement == 0.0:
88
+ return float("nan")
89
+ return 1.0 - float((w * observed).sum()) / expected_disagreement
90
+
91
+
92
+ def krippendorff_alpha(
93
+ values_by_unit: Sequence[FloatArray],
94
+ level: Literal["nominal", "interval"] = "nominal",
95
+ ) -> float:
96
+ """Krippendorff's alpha over units rated by any number of raters.
97
+
98
+ ``values_by_unit`` holds, per unit, the ratings that were actually given;
99
+ missing ratings are simply absent, which is the point of alpha. Units with
100
+ fewer than two ratings carry no agreement information and are skipped.
101
+ """
102
+
103
+ def distance(u: float, v: float) -> float:
104
+ if level == "interval":
105
+ return (u - v) ** 2
106
+ if level == "nominal":
107
+ return 0.0 if u == v else 1.0
108
+ raise ValueError(f"unknown level {level!r}")
109
+
110
+ units = [[float(v) for v in unit] for unit in values_by_unit if len(unit) >= 2]
111
+ if not units:
112
+ raise ValueError("no unit has two or more ratings")
113
+ n_pairable = float(sum(len(unit) for unit in units))
114
+
115
+ observed = 0.0
116
+ for unit in units:
117
+ m = len(unit)
118
+ pair_sum = 0.0
119
+ for i in range(m):
120
+ for j in range(m):
121
+ if i != j:
122
+ pair_sum += distance(unit[i], unit[j])
123
+ observed += pair_sum / (m - 1)
124
+ observed /= n_pairable
125
+
126
+ pooled = Counter(value for unit in units for value in unit)
127
+ expected = 0.0
128
+ for u, n_u in pooled.items():
129
+ for v, n_v in pooled.items():
130
+ if u != v:
131
+ expected += n_u * n_v * distance(u, v)
132
+ expected /= n_pairable * (n_pairable - 1.0)
133
+
134
+ if expected == 0.0:
135
+ return float("nan")
136
+ return 1.0 - observed / expected
137
+
138
+
139
+ def rank_average(values: FloatArray) -> NDArray[np.float64]:
140
+ """Ranks starting at 1, ties sharing the average rank."""
141
+ x = np.asarray(values, dtype=np.float64)
142
+ order = np.argsort(x, kind="stable")
143
+ ranks = np.empty(x.size, dtype=np.float64)
144
+ sorted_x = x[order]
145
+ i = 0
146
+ while i < x.size:
147
+ j = i
148
+ while j + 1 < x.size and sorted_x[j + 1] == sorted_x[i]:
149
+ j += 1
150
+ ranks[order[i : j + 1]] = (i + j) / 2.0 + 1.0
151
+ i = j + 1
152
+ return ranks
153
+
154
+
155
+ def pearson(a: FloatArray, b: FloatArray) -> float:
156
+ x, y = _as_pair(a, b)
157
+ xc = x - x.mean()
158
+ yc = y - y.mean()
159
+ denominator = float(np.sqrt((xc**2).sum() * (yc**2).sum()))
160
+ if denominator == 0.0:
161
+ return float("nan")
162
+ return float((xc * yc).sum()) / denominator
163
+
164
+
165
+ def spearman(a: FloatArray, b: FloatArray) -> float:
166
+ """Spearman rank correlation with average ranks for ties."""
167
+ return pearson(rank_average(a), rank_average(b))
168
+
169
+
170
+ def exact_agreement(a: FloatArray, b: FloatArray) -> float:
171
+ x, y = _as_pair(a, b)
172
+ return float((x == y).mean())
173
+
174
+
175
+ def within_one(a: FloatArray, b: FloatArray) -> float:
176
+ """Share of ratings at most one scale point apart, for integer scales."""
177
+ x, y = _as_pair(a, b)
178
+ return float((np.abs(x - y) <= 1.0 + 1e-12).mean())
judgekit/audit.py ADDED
@@ -0,0 +1,448 @@
1
+ """Run every applicable check on a judge's verdicts and collect the results.
2
+
3
+ The audit takes two verdict files — the judge's and the reference (usually
4
+ human) — and produces one ``AuditReport``: agreement, calibration, bias probes
5
+ and consistency, each as a ``ProbeResult`` with a bootstrap interval. Which
6
+ checks run is decided by what the data contains; nothing is required beyond
7
+ matching ``item_id`` values.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Sequence
13
+ from dataclasses import dataclass, field
14
+
15
+ from judgekit import bias, consistency
16
+ from judgekit.agreement import cohen_kappa, exact_agreement, spearman, within_one
17
+ from judgekit.bootstrap import bootstrap_ci
18
+ from judgekit.calibration import brier, ece_equal_mass, murphy_decomposition, reliability_curve
19
+ from judgekit.io import align_on_items, by_kind
20
+ from judgekit.result import ProbeResult, SkippedProbe
21
+ from judgekit.schema import BinaryVerdict, GradedVerdict, PairwiseVerdict, Verdict
22
+
23
+ # The finite-sample floor of equal-mass ECE at the demo's scale is a few
24
+ # hundredths even for a perfectly calibrated judge, so the innocent band
25
+ # stops at 0.05 rather than zero.
26
+ INNOCENT_ECE = (0.0, 0.05)
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class AuditReport:
31
+ judge_id: str
32
+ n_graded: int
33
+ n_binary: int
34
+ n_pairwise: int
35
+ agreement: list[ProbeResult] = field(default_factory=list)
36
+ calibration: list[ProbeResult] = field(default_factory=list)
37
+ probes: list[ProbeResult] = field(default_factory=list)
38
+ skipped: list[SkippedProbe] = field(default_factory=list)
39
+ # Small data slices the figures are drawn from.
40
+ reliability: list[tuple[float, float, int]] | None = None
41
+ graded_scores: tuple[list[float], list[float]] | None = None
42
+
43
+ @property
44
+ def results(self) -> list[ProbeResult]:
45
+ return [*self.agreement, *self.calibration, *self.probes]
46
+
47
+ @property
48
+ def flags(self) -> list[ProbeResult]:
49
+ return [result for result in self.results if result.triggered is True]
50
+
51
+ def to_dict(self) -> dict[str, object]:
52
+ """JSON-safe view of the whole audit, for pipelines that gate on it."""
53
+ return {
54
+ "judge_id": self.judge_id,
55
+ "n_graded": self.n_graded,
56
+ "n_binary": self.n_binary,
57
+ "n_pairwise": self.n_pairwise,
58
+ "agreement": [result.to_dict() for result in self.agreement],
59
+ "calibration": [result.to_dict() for result in self.calibration],
60
+ "probes": [result.to_dict() for result in self.probes],
61
+ "flags": [flag.name for flag in self.flags],
62
+ "skipped": [{"name": skip.name, "needs": skip.needs} for skip in self.skipped],
63
+ }
64
+
65
+
66
+ def _grouped_scores(
67
+ pairs: Sequence[tuple[Verdict, Verdict]],
68
+ values: Sequence[tuple[float, float]],
69
+ ) -> list[list[tuple[float, float]]]:
70
+ units: dict[str, list[tuple[float, float]]] = {}
71
+ for (judge, _), row in zip(pairs, values, strict=True):
72
+ units.setdefault(judge.item_id, []).append(row)
73
+ return list(units.values())
74
+
75
+
76
+ def _pooled(units: list[list[tuple[float, float]]]) -> tuple[list[float], list[float]]:
77
+ flat = [row for unit in units for row in unit]
78
+ return [row[0] for row in flat], [row[1] for row in flat]
79
+
80
+
81
+ def _agreement_graded(
82
+ pairs: Sequence[tuple[GradedVerdict, GradedVerdict]], seed: int
83
+ ) -> list[ProbeResult]:
84
+ scale_min = pairs[0][0].scale_min
85
+ scale_max = pairs[0][0].scale_max
86
+ values = [(judge.score, human.score) for judge, human in pairs]
87
+ units = _grouped_scores(pairs, values)
88
+ results = []
89
+
90
+ integral = all(
91
+ float(judge).is_integer() and float(human).is_integer() for judge, human in values
92
+ )
93
+ if integral and scale_min.is_integer() and scale_max.is_integer():
94
+ labels = [float(v) for v in range(int(scale_min), int(scale_max) + 1)]
95
+
96
+ def kappa(sample: list[list[tuple[float, float]]]) -> float:
97
+ judge_scores, human_scores = _pooled(sample)
98
+ return cohen_kappa(judge_scores, human_scores, labels=labels, weights="quadratic")
99
+
100
+ value = kappa(units)
101
+ results.append(
102
+ ProbeResult(
103
+ name="quadratic-weighted kappa",
104
+ value=value,
105
+ ci=bootstrap_ci(kappa, units, seed=seed),
106
+ innocent=None,
107
+ n=len(values),
108
+ detail=(
109
+ f"chance-corrected ordinal agreement with the reference "
110
+ f"on {len(values)} verdicts"
111
+ ),
112
+ )
113
+ )
114
+
115
+ def rho(sample: list[list[tuple[float, float]]]) -> float:
116
+ judge_scores, human_scores = _pooled(sample)
117
+ return spearman(judge_scores, human_scores)
118
+
119
+ results.append(
120
+ ProbeResult(
121
+ name="spearman rho",
122
+ value=rho(units),
123
+ ci=bootstrap_ci(rho, units, seed=seed),
124
+ innocent=None,
125
+ n=len(values),
126
+ detail="rank correlation with the reference scores",
127
+ )
128
+ )
129
+
130
+ if integral:
131
+
132
+ def exact(sample: list[list[tuple[float, float]]]) -> float:
133
+ judge_scores, human_scores = _pooled(sample)
134
+ return exact_agreement(judge_scores, human_scores)
135
+
136
+ def close(sample: list[list[tuple[float, float]]]) -> float:
137
+ judge_scores, human_scores = _pooled(sample)
138
+ return within_one(judge_scores, human_scores)
139
+
140
+ results.append(
141
+ ProbeResult(
142
+ name="exact agreement",
143
+ value=exact(units),
144
+ ci=bootstrap_ci(exact, units, seed=seed),
145
+ innocent=None,
146
+ n=len(values),
147
+ detail="share of verdicts equal to the reference score",
148
+ )
149
+ )
150
+ results.append(
151
+ ProbeResult(
152
+ name="within one point",
153
+ value=close(units),
154
+ ci=bootstrap_ci(close, units, seed=seed),
155
+ innocent=None,
156
+ n=len(values),
157
+ detail="share of verdicts at most one scale point from the reference",
158
+ )
159
+ )
160
+ return results
161
+
162
+
163
+ def _agreement_binary(
164
+ pairs: Sequence[tuple[BinaryVerdict, BinaryVerdict]], seed: int
165
+ ) -> list[ProbeResult]:
166
+ values = [(1.0 if judge.label else 0.0, 1.0 if human.label else 0.0) for judge, human in pairs]
167
+ units = _grouped_scores(pairs, values)
168
+
169
+ def accuracy(sample: list[list[tuple[float, float]]]) -> float:
170
+ judge_labels, human_labels = _pooled(sample)
171
+ return exact_agreement(judge_labels, human_labels)
172
+
173
+ def kappa(sample: list[list[tuple[float, float]]]) -> float:
174
+ judge_labels, human_labels = _pooled(sample)
175
+ return cohen_kappa(judge_labels, human_labels, labels=[0.0, 1.0])
176
+
177
+ return [
178
+ ProbeResult(
179
+ name="label accuracy",
180
+ value=accuracy(units),
181
+ ci=bootstrap_ci(accuracy, units, seed=seed),
182
+ innocent=None,
183
+ n=len(values),
184
+ detail=f"share of pass/fail labels matching the reference on {len(values)} verdicts",
185
+ ),
186
+ ProbeResult(
187
+ name="cohen kappa",
188
+ value=kappa(units),
189
+ ci=bootstrap_ci(kappa, units, seed=seed),
190
+ innocent=None,
191
+ n=len(values),
192
+ detail="chance-corrected label agreement",
193
+ ),
194
+ ]
195
+
196
+
197
+ def _agreement_pairwise(
198
+ pairs: Sequence[tuple[PairwiseVerdict, PairwiseVerdict]], seed: int
199
+ ) -> list[ProbeResult]:
200
+ categories = {"a": 0.0, "b": 1.0, "tie": 2.0}
201
+ values = [(categories[judge.choice], categories[human.choice]) for judge, human in pairs]
202
+ units = _grouped_scores(pairs, values)
203
+
204
+ def match(sample: list[list[tuple[float, float]]]) -> float:
205
+ judge_choices, human_choices = _pooled(sample)
206
+ return exact_agreement(judge_choices, human_choices)
207
+
208
+ return [
209
+ ProbeResult(
210
+ name="choice agreement",
211
+ value=match(units),
212
+ ci=bootstrap_ci(match, units, seed=seed),
213
+ innocent=None,
214
+ n=len(values),
215
+ detail=(
216
+ f"share of presentations whose canonical choice matches the reference "
217
+ f"on {len(values)} verdicts"
218
+ ),
219
+ )
220
+ ]
221
+
222
+
223
+ def _calibration(
224
+ pairs: Sequence[tuple[BinaryVerdict, BinaryVerdict]], seed: int
225
+ ) -> tuple[list[ProbeResult], list[tuple[float, float, int]] | None]:
226
+ confident = [
227
+ (judge.p_positive, 1.0 if human.label else 0.0)
228
+ for judge, human in pairs
229
+ if judge.p_positive is not None
230
+ ]
231
+ if len(confident) < 20:
232
+ return [], None
233
+ values = [(p, y) for p, y in confident if p is not None]
234
+ units = _grouped_scores(
235
+ [pair for pair in pairs if pair[0].p_positive is not None], values
236
+ )
237
+
238
+ def ece(sample: list[list[tuple[float, float]]]) -> float:
239
+ probabilities, outcomes = _pooled(sample)
240
+ return ece_equal_mass(probabilities, outcomes)
241
+
242
+ probabilities, outcomes = _pooled(units)
243
+ reliability_probe = ProbeResult(
244
+ name="expected calibration error",
245
+ value=ece(units),
246
+ ci=bootstrap_ci(ece, units, seed=seed),
247
+ innocent=INNOCENT_ECE,
248
+ n=len(values),
249
+ detail="mean gap between claimed confidence and observed frequency, equal-mass bins",
250
+ )
251
+ rel, res, unc = murphy_decomposition(probabilities, outcomes)
252
+ decomposition = ProbeResult(
253
+ name="brier score",
254
+ value=brier(probabilities, outcomes),
255
+ ci=(float("nan"), float("nan")),
256
+ innocent=None,
257
+ n=len(values),
258
+ detail=(
259
+ f"decomposes exactly into reliability {rel:.3f} - resolution {res:.3f} "
260
+ f"+ uncertainty {unc:.3f}"
261
+ ),
262
+ )
263
+ return [reliability_probe, decomposition], reliability_curve(probabilities, outcomes)
264
+
265
+
266
+ def run_audit(
267
+ judge: Sequence[Verdict],
268
+ human: Sequence[Verdict],
269
+ *,
270
+ judge_model: str | None = None,
271
+ seed: int = 0,
272
+ ) -> AuditReport:
273
+ """Audit one judge against one reference; see the module docstring."""
274
+ judge_ids = {verdict.judge_id for verdict in judge}
275
+ if len(judge_ids) > 1:
276
+ raise ValueError(
277
+ f"one audit reads one judge, got judge_ids {sorted(judge_ids)}; "
278
+ "split the verdicts and audit each judge separately"
279
+ )
280
+ judge_graded, judge_binary, judge_pairwise = by_kind(judge)
281
+ human_graded, human_binary, human_pairwise = by_kind(human)
282
+ judge_id = judge[0].judge_id if judge else "judge"
283
+
284
+ agreement: list[ProbeResult] = []
285
+ calibration: list[ProbeResult] = []
286
+ probes: list[ProbeResult] = []
287
+ skipped: list[SkippedProbe] = []
288
+ reliability = None
289
+ graded_scores = None
290
+
291
+ graded_pairs = align_on_items(judge_graded, human_graded) if human_graded else []
292
+ if judge_graded and not graded_pairs:
293
+ skipped.append(
294
+ SkippedProbe(
295
+ "graded agreement and bias probes",
296
+ "reference graded verdicts sharing item_id values with the judge's",
297
+ )
298
+ )
299
+ if graded_pairs:
300
+ scales = {(v.scale_min, v.scale_max) for v, _ in graded_pairs} | {
301
+ (v.scale_min, v.scale_max) for _, v in graded_pairs
302
+ }
303
+ if len(scales) > 1:
304
+ raise ValueError(f"mixed score scales in one audit: {sorted(scales)}")
305
+ agreement.extend(_agreement_graded(graded_pairs, seed))
306
+ graded_scores = (
307
+ [judge_verdict.score for judge_verdict, _ in graded_pairs],
308
+ [human_verdict.score for _, human_verdict in graded_pairs],
309
+ )
310
+ verbosity_probe = bias.verbosity_graded(graded_pairs, seed=seed)
311
+ if verbosity_probe is not None:
312
+ probes.append(verbosity_probe)
313
+ else:
314
+ skipped.append(
315
+ SkippedProbe("verbosity bias", "candidate_len on at least 3 graded verdicts")
316
+ )
317
+
318
+ if judge_model is None:
319
+ skipped.append(
320
+ SkippedProbe(
321
+ "self-preference",
322
+ "the judge's model name (judge_model / --judge-model) "
323
+ "plus candidate_model on graded verdicts",
324
+ )
325
+ )
326
+ else:
327
+ self_probe = bias.self_preference(graded_pairs, judge_model, seed=seed)
328
+ if self_probe is not None:
329
+ probes.append(self_probe)
330
+ else:
331
+ skipped.append(
332
+ SkippedProbe(
333
+ "self-preference",
334
+ "candidate_model on graded verdicts, with both own-model "
335
+ "and other-model candidates present",
336
+ )
337
+ )
338
+
339
+ spread_probe = bias.central_tendency(graded_pairs, seed=seed)
340
+ if spread_probe is not None:
341
+ probes.append(spread_probe)
342
+ else:
343
+ skipped.append(
344
+ SkippedProbe(
345
+ "central tendency",
346
+ "at least 3 aligned graded verdicts with varying reference scores",
347
+ )
348
+ )
349
+
350
+ binary_pairs = align_on_items(judge_binary, human_binary) if human_binary else []
351
+ if judge_binary and not binary_pairs:
352
+ skipped.append(
353
+ SkippedProbe(
354
+ "binary agreement and calibration",
355
+ "reference binary verdicts sharing item_id values with the judge's",
356
+ )
357
+ )
358
+ if binary_pairs:
359
+ agreement.extend(_agreement_binary(binary_pairs, seed))
360
+ calibration, reliability = _calibration(binary_pairs, seed)
361
+ if not calibration:
362
+ confident = sum(1 for j, _ in binary_pairs if j.p_positive is not None)
363
+ skipped.append(
364
+ SkippedProbe(
365
+ "calibration (ECE, Brier)",
366
+ f"confidence on at least 20 aligned binary verdicts (found {confident})",
367
+ )
368
+ )
369
+
370
+ if judge_pairwise:
371
+ pairwise_pairs = (
372
+ align_on_items(judge_pairwise, human_pairwise) if human_pairwise else []
373
+ )
374
+ if not pairwise_pairs:
375
+ skipped.append(
376
+ SkippedProbe(
377
+ "choice agreement and verbosity bias (pairwise)",
378
+ "reference pairwise verdicts sharing item_id values with the judge's",
379
+ )
380
+ )
381
+ else:
382
+ agreement.extend(_agreement_pairwise(pairwise_pairs, seed))
383
+ verbosity = bias.verbosity_pairwise(pairwise_pairs, seed=seed)
384
+ if verbosity is not None:
385
+ probes.append(verbosity)
386
+ else:
387
+ skipped.append(
388
+ SkippedProbe(
389
+ "verbosity bias (pairwise)",
390
+ "a_len and b_len on pairwise verdicts, with unequal lengths "
391
+ "and decisive verdicts on both sides",
392
+ )
393
+ )
394
+ for probe, skip in (
395
+ (
396
+ bias.position_preference(judge_pairwise, seed=seed),
397
+ SkippedProbe(
398
+ "position preference", "at least one decisive (non-tie) pairwise verdict"
399
+ ),
400
+ ),
401
+ (
402
+ bias.swap_flip_rate(judge_pairwise, seed=seed),
403
+ SkippedProbe(
404
+ "swap flip rate",
405
+ "each pair judged in both presentation orders (swapped false and true)",
406
+ ),
407
+ ),
408
+ (
409
+ bias.identical_pair_decisiveness(judge_pairwise, seed=seed),
410
+ SkippedProbe(
411
+ "identical-pair decisiveness",
412
+ 'pairs of identical candidates marked meta {"identical": true}',
413
+ ),
414
+ ),
415
+ ):
416
+ if probe is not None:
417
+ probes.append(probe)
418
+ else:
419
+ skipped.append(skip)
420
+
421
+ stability_ran = False
422
+ for kind_verdicts in (judge_graded, judge_binary, judge_pairwise):
423
+ if kind_verdicts:
424
+ stability = consistency.resample_consistency(kind_verdicts, seed=seed)
425
+ if stability is not None:
426
+ probes.append(stability)
427
+ stability_ran = True
428
+ if judge and not stability_ran:
429
+ skipped.append(
430
+ SkippedProbe(
431
+ "re-judgment unanimity",
432
+ "the same item judged more than once (sample_index 0..k-1, "
433
+ "per presentation order for pairwise)",
434
+ )
435
+ )
436
+
437
+ return AuditReport(
438
+ judge_id=judge_id,
439
+ n_graded=len(judge_graded),
440
+ n_binary=len(judge_binary),
441
+ n_pairwise=len(judge_pairwise),
442
+ agreement=agreement,
443
+ calibration=calibration,
444
+ probes=probes,
445
+ skipped=skipped,
446
+ reliability=reliability,
447
+ graded_scores=graded_scores,
448
+ )