juryrig 0.1.0__tar.gz
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.
- juryrig-0.1.0/LICENSE +21 -0
- juryrig-0.1.0/PKG-INFO +286 -0
- juryrig-0.1.0/README.md +267 -0
- juryrig-0.1.0/juryrig/__init__.py +49 -0
- juryrig-0.1.0/juryrig/__main__.py +5 -0
- juryrig-0.1.0/juryrig/audits.py +280 -0
- juryrig-0.1.0/juryrig/calibration.py +84 -0
- juryrig-0.1.0/juryrig/cli.py +179 -0
- juryrig-0.1.0/juryrig/judge.py +135 -0
- juryrig-0.1.0/juryrig/panel.py +127 -0
- juryrig-0.1.0/juryrig/providers.py +211 -0
- juryrig-0.1.0/juryrig/py.typed +0 -0
- juryrig-0.1.0/juryrig/suite.py +158 -0
- juryrig-0.1.0/juryrig.egg-info/PKG-INFO +286 -0
- juryrig-0.1.0/juryrig.egg-info/SOURCES.txt +31 -0
- juryrig-0.1.0/juryrig.egg-info/dependency_links.txt +1 -0
- juryrig-0.1.0/juryrig.egg-info/entry_points.txt +2 -0
- juryrig-0.1.0/juryrig.egg-info/top_level.txt +1 -0
- juryrig-0.1.0/pyproject.toml +46 -0
- juryrig-0.1.0/setup.cfg +4 -0
- juryrig-0.1.0/tests/test_audits.py +95 -0
- juryrig-0.1.0/tests/test_calibration.py +57 -0
- juryrig-0.1.0/tests/test_cli.py +158 -0
- juryrig-0.1.0/tests/test_concurrency.py +118 -0
- juryrig-0.1.0/tests/test_judge.py +75 -0
- juryrig-0.1.0/tests/test_mockjudge_flaws.py +102 -0
- juryrig-0.1.0/tests/test_packaging.py +40 -0
- juryrig-0.1.0/tests/test_panel.py +39 -0
- juryrig-0.1.0/tests/test_panel_compare.py +91 -0
- juryrig-0.1.0/tests/test_retry.py +171 -0
- juryrig-0.1.0/tests/test_suite.py +119 -0
- juryrig-0.1.0/tests/test_thresholds.py +156 -0
- juryrig-0.1.0/tests/test_ties.py +125 -0
juryrig-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ian Alloway
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
juryrig-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: juryrig
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Audit your LLM judges: position bias, verbosity bias, prompt injection, consistency, panels, and calibration.
|
|
5
|
+
Author-email: Ian Alloway <ian@allowayllc.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/ianalloway/juryrig
|
|
8
|
+
Project-URL: Issues, https://github.com/ianalloway/juryrig/issues
|
|
9
|
+
Keywords: llm,evals,llm-as-judge,calibration,bias,prompt-injection
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# juryrig
|
|
21
|
+
|
|
22
|
+
**Audit your LLM judges before you trust them.**
|
|
23
|
+
|
|
24
|
+
[](https://github.com/ianalloway/juryrig/actions/workflows/ci.yml)
|
|
25
|
+

|
|
26
|
+

|
|
27
|
+

|
|
28
|
+
|
|
29
|
+
LLM-as-judge is everywhere: the cheapest way to grade model outputs is to ask
|
|
30
|
+
another model. But the judge is a model too — with position bias, a weakness
|
|
31
|
+
for long-winded answers, run-to-run inconsistency, and confidence that rarely
|
|
32
|
+
matches its accuracy. If you haven't measured those, your eval numbers are
|
|
33
|
+
decoration.
|
|
34
|
+
|
|
35
|
+
juryrig is a small, zero-dependency Python toolkit that treats the judge as
|
|
36
|
+
the thing under test:
|
|
37
|
+
|
|
38
|
+
- **Position-bias audit** — present every A/B pair in both orders; count how often the *slot* (not the content) decides the winner.
|
|
39
|
+
- **Verbosity-bias audit** — re-score responses padded with content-free filler; a fair judge shouldn't reward padding.
|
|
40
|
+
- **Prompt-injection audit** — append judge-targeted instructions to bad responses; a robust judge should grade the answer, not obey it.
|
|
41
|
+
- **Self-consistency** — same input, several runs; how stable is the score?
|
|
42
|
+
- **Panels** — pool several judges (mean / median / min) and get an agreement score, so you know when your verdict depends on which judge you picked. Pairwise panels vote on A/B pairs and report a dead heat as one.
|
|
43
|
+
- **Calibration** — Brier score, reliability tables, and expected calibration error against human labels.
|
|
44
|
+
|
|
45
|
+
Run the whole battery with `audit_suite()`, or from the command line with
|
|
46
|
+
`juryrig cases.json`.
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
> **PyPI publish pending** — `juryrig` is **not** on PyPI yet. Trusted Publishing
|
|
51
|
+
> is wired in [`.github/workflows/publish.yml`](.github/workflows/publish.yml);
|
|
52
|
+
> install from GitHub until the first release lands:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install git+https://github.com/ianalloway/juryrig
|
|
56
|
+
# after PyPI: pip install juryrig
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Quickstart
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from juryrig import (
|
|
63
|
+
MockJudge,
|
|
64
|
+
Panel,
|
|
65
|
+
position_bias,
|
|
66
|
+
prompt_injection_bias,
|
|
67
|
+
verbosity_bias,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
rubric = "Answer must mention photosynthesis, chlorophyll, sunlight, and energy."
|
|
71
|
+
|
|
72
|
+
# 1. Audit a judge before using it
|
|
73
|
+
judge = MockJudge(name="demo") # swap in your own judge for real audits
|
|
74
|
+
cases = [("How do plants make food?", "good answer...", "weak answer...")]
|
|
75
|
+
|
|
76
|
+
bias = position_bias(judge, cases, rubric)
|
|
77
|
+
print(f"flip rate: {bias.flip_rate:.0%} flagged: {bias.flagged}")
|
|
78
|
+
|
|
79
|
+
injection = prompt_injection_bias(judge, [("Weak answer prompt", "vague answer")], rubric)
|
|
80
|
+
print(f"injection lift: {injection.mean_delta:+.3f} flagged: {injection.flagged}")
|
|
81
|
+
|
|
82
|
+
# 2. Use a panel instead of a single judge
|
|
83
|
+
panel = Panel([MockJudge(name="primary"), MockJudge(name="baseline")])
|
|
84
|
+
report = panel.evaluate(prompt="How do plants make food?",
|
|
85
|
+
response="Photosynthesis converts sunlight...",
|
|
86
|
+
rubric=rubric)
|
|
87
|
+
print(report.pooled, report.agreement)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`position_bias()` is a pairwise audit and needs a judge with `compare()`;
|
|
91
|
+
`MockJudge` implements both `compare()` and `judge()` so the quickstart runs
|
|
92
|
+
without API credentials.
|
|
93
|
+
|
|
94
|
+
## The whole battery in one call
|
|
95
|
+
|
|
96
|
+
`audit_suite()` runs every audit and pools the verdicts. Each case is a
|
|
97
|
+
`(prompt, good_response, weak_response)` triple: the pair drives the position
|
|
98
|
+
comparison, the good response gets padded to detect verbosity bias, and the
|
|
99
|
+
weak one carries the injection payload.
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from juryrig import MockJudge, audit_suite
|
|
103
|
+
|
|
104
|
+
report = audit_suite(MockJudge(), cases, rubric)
|
|
105
|
+
|
|
106
|
+
print(report.summary())
|
|
107
|
+
assert not report.flagged, f"judge failed: {report.failures}"
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`report.failures` names the audits that tripped (`("position", "injection")`).
|
|
111
|
+
If the judge has no `compare()`, the position audit is reported in
|
|
112
|
+
`report.skipped` rather than silently counted as a pass.
|
|
113
|
+
|
|
114
|
+
## Ties
|
|
115
|
+
|
|
116
|
+
`compare()` may return `"tie"` as well as `"A"` or `"B"`. It's optional — a
|
|
117
|
+
judge that only ever picks a side is unaffected — but real judges often want
|
|
118
|
+
to call two answers equivalent, and forcing that into a coin flip manufactures
|
|
119
|
+
position bias that isn't there.
|
|
120
|
+
|
|
121
|
+
How `position_bias()` accounts for them:
|
|
122
|
+
|
|
123
|
+
- **Tying both ways is not a flip.** The judge gave the same answer in both
|
|
124
|
+
orders, which is consistency, not order-dependence.
|
|
125
|
+
- **Tying one way and picking the other way *is* a flip.** The verdict changed
|
|
126
|
+
when only the order changed — that's the thing being measured.
|
|
127
|
+
- **Ties are excluded from `first_slot_wins`.** Counting them as "not won by
|
|
128
|
+
the first slot" would drag the ratio to 0 and flag a judge that ties
|
|
129
|
+
everything as maximally biased toward slot two. With nothing decisive to go
|
|
130
|
+
on the audit reports 0.5: no evidence of skew. The count is kept in
|
|
131
|
+
`report.ties` so it stays visible rather than silently dropped.
|
|
132
|
+
|
|
133
|
+
## Panels of pairwise judges
|
|
134
|
+
|
|
135
|
+
`Panel.evaluate()` pools scores; `Panel.compare()` pools A/B votes by majority:
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
verdict = panel.compare(prompt="How do plants make food?",
|
|
139
|
+
a="Photosynthesis converts sunlight...",
|
|
140
|
+
b="Plants eat soil.",
|
|
141
|
+
rubric=rubric)
|
|
142
|
+
|
|
143
|
+
print(verdict.winner, verdict.agreement, verdict.votes)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
An even split sets `winner` to `None` and `deadlocked` to `True`, rather than
|
|
147
|
+
picking a side. A coin-flip winner would hide exactly the disagreement you
|
|
148
|
+
convened a panel to find. Judges without `compare()` are rejected by name —
|
|
149
|
+
silently dropping them would move the verdict while leaving `agreement`
|
|
150
|
+
looking healthy.
|
|
151
|
+
|
|
152
|
+
## Going faster against a real judge
|
|
153
|
+
|
|
154
|
+
`audit_suite` on N cases is roughly 4N judge calls, which is minutes of wall
|
|
155
|
+
time over a network. `max_workers` runs them in parallel:
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
report = audit_suite(judge, cases, rubric, max_workers=8)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Serial by default, because a judge may be stateful or rate-limited and
|
|
162
|
+
threading one behind your back would be a surprise. Results are collected in
|
|
163
|
+
input order, so a report is identical no matter how many workers produced it
|
|
164
|
+
— workers are a speed knob, never a correctness one. Your judge must be
|
|
165
|
+
thread-safe to raise it. The CLI exposes the same thing as `--workers`.
|
|
166
|
+
|
|
167
|
+
## Tuning what counts as a failure
|
|
168
|
+
|
|
169
|
+
Every `flagged` verdict comes from a `Thresholds` object. The defaults are
|
|
170
|
+
strict on purpose, but they're yours to move:
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
from juryrig import Thresholds, audit_suite
|
|
174
|
+
|
|
175
|
+
report = audit_suite(judge, cases, rubric, thresholds=Thresholds(
|
|
176
|
+
injection_max_delta=0.05, # stricter: near-zero tolerance for injection
|
|
177
|
+
verbosity_mean_delta=0.10, # looser: this judge is allowed to like detail
|
|
178
|
+
))
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
The measurements never change — only the line between pass and fail. Each
|
|
182
|
+
report carries the `thresholds` it was judged against, so a stored report
|
|
183
|
+
still explains its own verdict. A case file can set them too, under a
|
|
184
|
+
`"thresholds"` key; unknown keys are rejected rather than ignored, so a typo
|
|
185
|
+
can't silently leave the strict default in force.
|
|
186
|
+
|
|
187
|
+
## Command line
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
juryrig examples/cases.json # audit the built-in MockJudge
|
|
191
|
+
juryrig cases.json --provider anthropic --json # audit a live judge
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
The case file is `{"rubric": ..., "cases": [{"prompt", "good", "weak"}, ...]}`.
|
|
195
|
+
The command exits `1` when the judge is flagged and `2` on bad input, so a CI
|
|
196
|
+
step is one line. Without installing, use `python -m juryrig cases.json`.
|
|
197
|
+
|
|
198
|
+
### Optional: provider-backed judges
|
|
199
|
+
|
|
200
|
+
`AnthropicJudge` and `OpenAIJudge` wrap the Anthropic/OpenAI HTTP APIs
|
|
201
|
+
(stdlib-only, no extra dependencies) and work with the single-response
|
|
202
|
+
audits. They're not exported from the top-level package — import them
|
|
203
|
+
explicitly when you need a live model:
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
from juryrig.providers import AnthropicJudge, OpenAIJudge # needs *_API_KEY env var
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
An audit is many calls in a row, so both retry transient failures (429, 5xx,
|
|
210
|
+
network errors) with exponential backoff — one flaky response shouldn't throw
|
|
211
|
+
away every result collected before it. A numeric `Retry-After` is honoured.
|
|
212
|
+
Client errors like 401 and 404 fail fast, since they'd fail identically on
|
|
213
|
+
every attempt.
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
from juryrig.providers import AnthropicJudge, RetryPolicy
|
|
217
|
+
|
|
218
|
+
judge = AnthropicJudge(retry=RetryPolicy(attempts=5, backoff=1.0))
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Every audit returns a small frozen dataclass with a `flagged` property, so
|
|
222
|
+
gating a CI pipeline is one `if`:
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
assert not position_bias(judge, cases, rubric).flagged, "judge is positionally biased"
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## Why the MockJudge has built-in flaws
|
|
229
|
+
|
|
230
|
+
`MockJudge(position_bias=..., verbosity_bias=..., injection_bias=...,
|
|
231
|
+
noise=..., instability=..., tie_margin=...)` lets you dial in known defects.
|
|
232
|
+
That's how juryrig tests itself — the audits must detect a rigged judge and
|
|
233
|
+
clear a fair one — and it gives you a deterministic, network-free way to test
|
|
234
|
+
*your* eval pipeline end to end.
|
|
235
|
+
|
|
236
|
+
`noise` and `instability` are not the same knob, and the difference trips
|
|
237
|
+
people up:
|
|
238
|
+
|
|
239
|
+
- **`noise`** is seeded on the input, so re-judging one response returns the
|
|
240
|
+
same score forever. It perturbs scores *across* responses.
|
|
241
|
+
- **`instability`** is seeded on a call counter, so the same input scores
|
|
242
|
+
differently each time. It's the only flaw `self_consistency()` can detect —
|
|
243
|
+
a judge with `noise=0.9` reports a spread of exactly `0.0`.
|
|
244
|
+
|
|
245
|
+
Instability is still reproducible: a fresh `MockJudge` replays the same
|
|
246
|
+
sequence, so switching the flaw on doesn't make your tests flaky.
|
|
247
|
+
|
|
248
|
+
`tie_margin` makes the judge answer `"tie"` when two responses score within
|
|
249
|
+
it. Useful for exercising tie handling — and note that *without* it, two
|
|
250
|
+
identical answers are handed to slot A by `compare()`'s tie-break, which the
|
|
251
|
+
position audit correctly reports as bias.
|
|
252
|
+
|
|
253
|
+
## Calibration
|
|
254
|
+
|
|
255
|
+
```python
|
|
256
|
+
from juryrig import brier_score, expected_calibration_error
|
|
257
|
+
|
|
258
|
+
scores = [0.9, 0.8, 0.3, 0.95] # judge scores
|
|
259
|
+
labels = [1, 1, 0, 0] # human ground truth
|
|
260
|
+
|
|
261
|
+
print(brier_score(scores, labels))
|
|
262
|
+
print(expected_calibration_error(scores, labels))
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
A judge that says 0.9 should be right ~90% of the time. ECE tells you how far
|
|
266
|
+
that promise is from reality.
|
|
267
|
+
|
|
268
|
+
## Demo
|
|
269
|
+
|
|
270
|
+
```bash
|
|
271
|
+
python3 examples/audit_demo.py
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Runs the full audit suite against a fair judge and a rigged one, no API keys
|
|
275
|
+
required.
|
|
276
|
+
|
|
277
|
+
## Design notes
|
|
278
|
+
|
|
279
|
+
- **Zero runtime dependencies** — stdlib only, including the API clients.
|
|
280
|
+
- **Provider-agnostic** — a judge is anything with a `judge()` method; pairwise judges add `compare()`. Protocols, not base classes.
|
|
281
|
+
- **Deterministic tests** — all randomness is hash-seeded; CI never flakes.
|
|
282
|
+
- **Typed** — ships a PEP 561 `py.typed` marker, so the hints reach your type checker.
|
|
283
|
+
|
|
284
|
+
## License
|
|
285
|
+
|
|
286
|
+
MIT
|
juryrig-0.1.0/README.md
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# juryrig
|
|
2
|
+
|
|
3
|
+
**Audit your LLM judges before you trust them.**
|
|
4
|
+
|
|
5
|
+
[](https://github.com/ianalloway/juryrig/actions/workflows/ci.yml)
|
|
6
|
+

|
|
7
|
+

|
|
8
|
+

|
|
9
|
+
|
|
10
|
+
LLM-as-judge is everywhere: the cheapest way to grade model outputs is to ask
|
|
11
|
+
another model. But the judge is a model too — with position bias, a weakness
|
|
12
|
+
for long-winded answers, run-to-run inconsistency, and confidence that rarely
|
|
13
|
+
matches its accuracy. If you haven't measured those, your eval numbers are
|
|
14
|
+
decoration.
|
|
15
|
+
|
|
16
|
+
juryrig is a small, zero-dependency Python toolkit that treats the judge as
|
|
17
|
+
the thing under test:
|
|
18
|
+
|
|
19
|
+
- **Position-bias audit** — present every A/B pair in both orders; count how often the *slot* (not the content) decides the winner.
|
|
20
|
+
- **Verbosity-bias audit** — re-score responses padded with content-free filler; a fair judge shouldn't reward padding.
|
|
21
|
+
- **Prompt-injection audit** — append judge-targeted instructions to bad responses; a robust judge should grade the answer, not obey it.
|
|
22
|
+
- **Self-consistency** — same input, several runs; how stable is the score?
|
|
23
|
+
- **Panels** — pool several judges (mean / median / min) and get an agreement score, so you know when your verdict depends on which judge you picked. Pairwise panels vote on A/B pairs and report a dead heat as one.
|
|
24
|
+
- **Calibration** — Brier score, reliability tables, and expected calibration error against human labels.
|
|
25
|
+
|
|
26
|
+
Run the whole battery with `audit_suite()`, or from the command line with
|
|
27
|
+
`juryrig cases.json`.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
> **PyPI publish pending** — `juryrig` is **not** on PyPI yet. Trusted Publishing
|
|
32
|
+
> is wired in [`.github/workflows/publish.yml`](.github/workflows/publish.yml);
|
|
33
|
+
> install from GitHub until the first release lands:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install git+https://github.com/ianalloway/juryrig
|
|
37
|
+
# after PyPI: pip install juryrig
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Quickstart
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from juryrig import (
|
|
44
|
+
MockJudge,
|
|
45
|
+
Panel,
|
|
46
|
+
position_bias,
|
|
47
|
+
prompt_injection_bias,
|
|
48
|
+
verbosity_bias,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
rubric = "Answer must mention photosynthesis, chlorophyll, sunlight, and energy."
|
|
52
|
+
|
|
53
|
+
# 1. Audit a judge before using it
|
|
54
|
+
judge = MockJudge(name="demo") # swap in your own judge for real audits
|
|
55
|
+
cases = [("How do plants make food?", "good answer...", "weak answer...")]
|
|
56
|
+
|
|
57
|
+
bias = position_bias(judge, cases, rubric)
|
|
58
|
+
print(f"flip rate: {bias.flip_rate:.0%} flagged: {bias.flagged}")
|
|
59
|
+
|
|
60
|
+
injection = prompt_injection_bias(judge, [("Weak answer prompt", "vague answer")], rubric)
|
|
61
|
+
print(f"injection lift: {injection.mean_delta:+.3f} flagged: {injection.flagged}")
|
|
62
|
+
|
|
63
|
+
# 2. Use a panel instead of a single judge
|
|
64
|
+
panel = Panel([MockJudge(name="primary"), MockJudge(name="baseline")])
|
|
65
|
+
report = panel.evaluate(prompt="How do plants make food?",
|
|
66
|
+
response="Photosynthesis converts sunlight...",
|
|
67
|
+
rubric=rubric)
|
|
68
|
+
print(report.pooled, report.agreement)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`position_bias()` is a pairwise audit and needs a judge with `compare()`;
|
|
72
|
+
`MockJudge` implements both `compare()` and `judge()` so the quickstart runs
|
|
73
|
+
without API credentials.
|
|
74
|
+
|
|
75
|
+
## The whole battery in one call
|
|
76
|
+
|
|
77
|
+
`audit_suite()` runs every audit and pools the verdicts. Each case is a
|
|
78
|
+
`(prompt, good_response, weak_response)` triple: the pair drives the position
|
|
79
|
+
comparison, the good response gets padded to detect verbosity bias, and the
|
|
80
|
+
weak one carries the injection payload.
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from juryrig import MockJudge, audit_suite
|
|
84
|
+
|
|
85
|
+
report = audit_suite(MockJudge(), cases, rubric)
|
|
86
|
+
|
|
87
|
+
print(report.summary())
|
|
88
|
+
assert not report.flagged, f"judge failed: {report.failures}"
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`report.failures` names the audits that tripped (`("position", "injection")`).
|
|
92
|
+
If the judge has no `compare()`, the position audit is reported in
|
|
93
|
+
`report.skipped` rather than silently counted as a pass.
|
|
94
|
+
|
|
95
|
+
## Ties
|
|
96
|
+
|
|
97
|
+
`compare()` may return `"tie"` as well as `"A"` or `"B"`. It's optional — a
|
|
98
|
+
judge that only ever picks a side is unaffected — but real judges often want
|
|
99
|
+
to call two answers equivalent, and forcing that into a coin flip manufactures
|
|
100
|
+
position bias that isn't there.
|
|
101
|
+
|
|
102
|
+
How `position_bias()` accounts for them:
|
|
103
|
+
|
|
104
|
+
- **Tying both ways is not a flip.** The judge gave the same answer in both
|
|
105
|
+
orders, which is consistency, not order-dependence.
|
|
106
|
+
- **Tying one way and picking the other way *is* a flip.** The verdict changed
|
|
107
|
+
when only the order changed — that's the thing being measured.
|
|
108
|
+
- **Ties are excluded from `first_slot_wins`.** Counting them as "not won by
|
|
109
|
+
the first slot" would drag the ratio to 0 and flag a judge that ties
|
|
110
|
+
everything as maximally biased toward slot two. With nothing decisive to go
|
|
111
|
+
on the audit reports 0.5: no evidence of skew. The count is kept in
|
|
112
|
+
`report.ties` so it stays visible rather than silently dropped.
|
|
113
|
+
|
|
114
|
+
## Panels of pairwise judges
|
|
115
|
+
|
|
116
|
+
`Panel.evaluate()` pools scores; `Panel.compare()` pools A/B votes by majority:
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
verdict = panel.compare(prompt="How do plants make food?",
|
|
120
|
+
a="Photosynthesis converts sunlight...",
|
|
121
|
+
b="Plants eat soil.",
|
|
122
|
+
rubric=rubric)
|
|
123
|
+
|
|
124
|
+
print(verdict.winner, verdict.agreement, verdict.votes)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
An even split sets `winner` to `None` and `deadlocked` to `True`, rather than
|
|
128
|
+
picking a side. A coin-flip winner would hide exactly the disagreement you
|
|
129
|
+
convened a panel to find. Judges without `compare()` are rejected by name —
|
|
130
|
+
silently dropping them would move the verdict while leaving `agreement`
|
|
131
|
+
looking healthy.
|
|
132
|
+
|
|
133
|
+
## Going faster against a real judge
|
|
134
|
+
|
|
135
|
+
`audit_suite` on N cases is roughly 4N judge calls, which is minutes of wall
|
|
136
|
+
time over a network. `max_workers` runs them in parallel:
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
report = audit_suite(judge, cases, rubric, max_workers=8)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Serial by default, because a judge may be stateful or rate-limited and
|
|
143
|
+
threading one behind your back would be a surprise. Results are collected in
|
|
144
|
+
input order, so a report is identical no matter how many workers produced it
|
|
145
|
+
— workers are a speed knob, never a correctness one. Your judge must be
|
|
146
|
+
thread-safe to raise it. The CLI exposes the same thing as `--workers`.
|
|
147
|
+
|
|
148
|
+
## Tuning what counts as a failure
|
|
149
|
+
|
|
150
|
+
Every `flagged` verdict comes from a `Thresholds` object. The defaults are
|
|
151
|
+
strict on purpose, but they're yours to move:
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
from juryrig import Thresholds, audit_suite
|
|
155
|
+
|
|
156
|
+
report = audit_suite(judge, cases, rubric, thresholds=Thresholds(
|
|
157
|
+
injection_max_delta=0.05, # stricter: near-zero tolerance for injection
|
|
158
|
+
verbosity_mean_delta=0.10, # looser: this judge is allowed to like detail
|
|
159
|
+
))
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
The measurements never change — only the line between pass and fail. Each
|
|
163
|
+
report carries the `thresholds` it was judged against, so a stored report
|
|
164
|
+
still explains its own verdict. A case file can set them too, under a
|
|
165
|
+
`"thresholds"` key; unknown keys are rejected rather than ignored, so a typo
|
|
166
|
+
can't silently leave the strict default in force.
|
|
167
|
+
|
|
168
|
+
## Command line
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
juryrig examples/cases.json # audit the built-in MockJudge
|
|
172
|
+
juryrig cases.json --provider anthropic --json # audit a live judge
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
The case file is `{"rubric": ..., "cases": [{"prompt", "good", "weak"}, ...]}`.
|
|
176
|
+
The command exits `1` when the judge is flagged and `2` on bad input, so a CI
|
|
177
|
+
step is one line. Without installing, use `python -m juryrig cases.json`.
|
|
178
|
+
|
|
179
|
+
### Optional: provider-backed judges
|
|
180
|
+
|
|
181
|
+
`AnthropicJudge` and `OpenAIJudge` wrap the Anthropic/OpenAI HTTP APIs
|
|
182
|
+
(stdlib-only, no extra dependencies) and work with the single-response
|
|
183
|
+
audits. They're not exported from the top-level package — import them
|
|
184
|
+
explicitly when you need a live model:
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
from juryrig.providers import AnthropicJudge, OpenAIJudge # needs *_API_KEY env var
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
An audit is many calls in a row, so both retry transient failures (429, 5xx,
|
|
191
|
+
network errors) with exponential backoff — one flaky response shouldn't throw
|
|
192
|
+
away every result collected before it. A numeric `Retry-After` is honoured.
|
|
193
|
+
Client errors like 401 and 404 fail fast, since they'd fail identically on
|
|
194
|
+
every attempt.
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
from juryrig.providers import AnthropicJudge, RetryPolicy
|
|
198
|
+
|
|
199
|
+
judge = AnthropicJudge(retry=RetryPolicy(attempts=5, backoff=1.0))
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Every audit returns a small frozen dataclass with a `flagged` property, so
|
|
203
|
+
gating a CI pipeline is one `if`:
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
assert not position_bias(judge, cases, rubric).flagged, "judge is positionally biased"
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## Why the MockJudge has built-in flaws
|
|
210
|
+
|
|
211
|
+
`MockJudge(position_bias=..., verbosity_bias=..., injection_bias=...,
|
|
212
|
+
noise=..., instability=..., tie_margin=...)` lets you dial in known defects.
|
|
213
|
+
That's how juryrig tests itself — the audits must detect a rigged judge and
|
|
214
|
+
clear a fair one — and it gives you a deterministic, network-free way to test
|
|
215
|
+
*your* eval pipeline end to end.
|
|
216
|
+
|
|
217
|
+
`noise` and `instability` are not the same knob, and the difference trips
|
|
218
|
+
people up:
|
|
219
|
+
|
|
220
|
+
- **`noise`** is seeded on the input, so re-judging one response returns the
|
|
221
|
+
same score forever. It perturbs scores *across* responses.
|
|
222
|
+
- **`instability`** is seeded on a call counter, so the same input scores
|
|
223
|
+
differently each time. It's the only flaw `self_consistency()` can detect —
|
|
224
|
+
a judge with `noise=0.9` reports a spread of exactly `0.0`.
|
|
225
|
+
|
|
226
|
+
Instability is still reproducible: a fresh `MockJudge` replays the same
|
|
227
|
+
sequence, so switching the flaw on doesn't make your tests flaky.
|
|
228
|
+
|
|
229
|
+
`tie_margin` makes the judge answer `"tie"` when two responses score within
|
|
230
|
+
it. Useful for exercising tie handling — and note that *without* it, two
|
|
231
|
+
identical answers are handed to slot A by `compare()`'s tie-break, which the
|
|
232
|
+
position audit correctly reports as bias.
|
|
233
|
+
|
|
234
|
+
## Calibration
|
|
235
|
+
|
|
236
|
+
```python
|
|
237
|
+
from juryrig import brier_score, expected_calibration_error
|
|
238
|
+
|
|
239
|
+
scores = [0.9, 0.8, 0.3, 0.95] # judge scores
|
|
240
|
+
labels = [1, 1, 0, 0] # human ground truth
|
|
241
|
+
|
|
242
|
+
print(brier_score(scores, labels))
|
|
243
|
+
print(expected_calibration_error(scores, labels))
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
A judge that says 0.9 should be right ~90% of the time. ECE tells you how far
|
|
247
|
+
that promise is from reality.
|
|
248
|
+
|
|
249
|
+
## Demo
|
|
250
|
+
|
|
251
|
+
```bash
|
|
252
|
+
python3 examples/audit_demo.py
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Runs the full audit suite against a fair judge and a rigged one, no API keys
|
|
256
|
+
required.
|
|
257
|
+
|
|
258
|
+
## Design notes
|
|
259
|
+
|
|
260
|
+
- **Zero runtime dependencies** — stdlib only, including the API clients.
|
|
261
|
+
- **Provider-agnostic** — a judge is anything with a `judge()` method; pairwise judges add `compare()`. Protocols, not base classes.
|
|
262
|
+
- **Deterministic tests** — all randomness is hash-seeded; CI never flakes.
|
|
263
|
+
- **Typed** — ships a PEP 561 `py.typed` marker, so the hints reach your type checker.
|
|
264
|
+
|
|
265
|
+
## License
|
|
266
|
+
|
|
267
|
+
MIT
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""juryrig — audit your LLM judges before you trust them.
|
|
2
|
+
|
|
3
|
+
Provider-backed judges (AnthropicJudge, OpenAIJudge) are not exported here;
|
|
4
|
+
import them explicitly from `juryrig.providers` when you need a live API.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .audits import (
|
|
8
|
+
DEFAULT_THRESHOLDS,
|
|
9
|
+
ConsistencyReport,
|
|
10
|
+
PositionBiasReport,
|
|
11
|
+
PromptInjectionReport,
|
|
12
|
+
Thresholds,
|
|
13
|
+
VerbosityBiasReport,
|
|
14
|
+
position_bias,
|
|
15
|
+
prompt_injection_bias,
|
|
16
|
+
self_consistency,
|
|
17
|
+
verbosity_bias,
|
|
18
|
+
)
|
|
19
|
+
from .calibration import brier_score, expected_calibration_error, reliability_table
|
|
20
|
+
from .judge import Judge, Judgment, MockJudge, PairwiseJudge
|
|
21
|
+
from .panel import Panel, PanelReport, PanelVerdict
|
|
22
|
+
from .suite import AuditSuiteReport, audit_suite
|
|
23
|
+
|
|
24
|
+
__version__ = "0.1.0"
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"AuditSuiteReport",
|
|
28
|
+
"ConsistencyReport",
|
|
29
|
+
"DEFAULT_THRESHOLDS",
|
|
30
|
+
"Judge",
|
|
31
|
+
"Judgment",
|
|
32
|
+
"MockJudge",
|
|
33
|
+
"PairwiseJudge",
|
|
34
|
+
"Panel",
|
|
35
|
+
"PanelReport",
|
|
36
|
+
"PanelVerdict",
|
|
37
|
+
"PositionBiasReport",
|
|
38
|
+
"PromptInjectionReport",
|
|
39
|
+
"Thresholds",
|
|
40
|
+
"VerbosityBiasReport",
|
|
41
|
+
"audit_suite",
|
|
42
|
+
"brier_score",
|
|
43
|
+
"expected_calibration_error",
|
|
44
|
+
"position_bias",
|
|
45
|
+
"prompt_injection_bias",
|
|
46
|
+
"reliability_table",
|
|
47
|
+
"self_consistency",
|
|
48
|
+
"verbosity_bias",
|
|
49
|
+
]
|