evalaudit 0.2.0__tar.gz → 0.2.1__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.
Files changed (28) hide show
  1. evalaudit-0.2.1/AUDIT.md +671 -0
  2. {evalaudit-0.2.0 → evalaudit-0.2.1}/PKG-INFO +1 -1
  3. {evalaudit-0.2.0 → evalaudit-0.2.1}/evalaudit/__init__.py +9 -1
  4. {evalaudit-0.2.0 → evalaudit-0.2.1}/evalaudit/_types.py +23 -5
  5. {evalaudit-0.2.0 → evalaudit-0.2.1}/evalaudit/audit.py +107 -18
  6. {evalaudit-0.2.0 → evalaudit-0.2.1}/pyproject.toml +1 -1
  7. {evalaudit-0.2.0 → evalaudit-0.2.1}/tests/test_agreement.py +58 -9
  8. {evalaudit-0.2.0 → evalaudit-0.2.1}/tests/test_audit.py +406 -1
  9. {evalaudit-0.2.0 → evalaudit-0.2.1}/tests/test_judge.py +66 -2
  10. evalaudit-0.2.1/tests/test_version.py +84 -0
  11. {evalaudit-0.2.0 → evalaudit-0.2.1}/.github/workflows/tests.yml +0 -0
  12. {evalaudit-0.2.0 → evalaudit-0.2.1}/.gitignore +0 -0
  13. {evalaudit-0.2.0 → evalaudit-0.2.1}/LICENSE +0 -0
  14. {evalaudit-0.2.0 → evalaudit-0.2.1}/README.md +0 -0
  15. {evalaudit-0.2.0 → evalaudit-0.2.1}/evalaudit/agreement.py +0 -0
  16. {evalaudit-0.2.0 → evalaudit-0.2.1}/evalaudit/compare.py +0 -0
  17. {evalaudit-0.2.0 → evalaudit-0.2.1}/evalaudit/judge.py +0 -0
  18. {evalaudit-0.2.0 → evalaudit-0.2.1}/evalaudit/pairwise.py +0 -0
  19. {evalaudit-0.2.0 → evalaudit-0.2.1}/evalaudit/power.py +0 -0
  20. {evalaudit-0.2.0 → evalaudit-0.2.1}/evalaudit/scores.py +0 -0
  21. {evalaudit-0.2.0 → evalaudit-0.2.1}/examples/.gitkeep +0 -0
  22. {evalaudit-0.2.0 → evalaudit-0.2.1}/examples/interval_method_study.py +0 -0
  23. {evalaudit-0.2.0 → evalaudit-0.2.1}/tests/__init__.py +0 -0
  24. {evalaudit-0.2.0 → evalaudit-0.2.1}/tests/test_compare.py +0 -0
  25. {evalaudit-0.2.0 → evalaudit-0.2.1}/tests/test_pairwise.py +0 -0
  26. {evalaudit-0.2.0 → evalaudit-0.2.1}/tests/test_power.py +0 -0
  27. {evalaudit-0.2.0 → evalaudit-0.2.1}/tests/test_readme.py +0 -0
  28. {evalaudit-0.2.0 → evalaudit-0.2.1}/tests/test_scores.py +0 -0
@@ -0,0 +1,671 @@
1
+ # Codebase audit: evalaudit
2
+
3
+ Status, 2026-09-08. Findings 1, 2, 4 and 5 are fixed on main. The rest are
4
+ open. Findings 3, 8, 9, 10 and 12 are regression risks on code that is
5
+ correct as shipped, not live errors.
6
+
7
+ Audited at commit `d38c7a3` on branch `copilot/create-audit-report`, 2026-09-07.
8
+ Working tree was clean at the time of the audit. Nothing in the repository was
9
+ modified except this file.
10
+
11
+ Method. I read every source file, every test file, the README, `pyproject.toml`,
12
+ the CI workflow, `examples/`, and the git history. Where a formula names a
13
+ source I checked it against the source or, where that was not available,
14
+ against a brute-force numerical solution of the same estimation problem. Where I
15
+ suspected a test had no teeth I proved it by copying the repository to a
16
+ scratch directory, breaking the implementation there, and running the suite. The
17
+ suite passes clean as shipped, 767 passed and 2 skipped at the audited commit.
18
+ 783 today.
19
+
20
+ Findings are ranked by consequence. Everything in "Definitely wrong" I have
21
+ demonstrated. Everything in "Suspected" I could not close out.
22
+
23
+ ---
24
+
25
+ ## Definitely wrong
26
+
27
+ ### 1. Position bias in the both-orders design is not tested at all, and the one test that looks like it guards the verdict is matching the wrong words
28
+
29
+ `evalaudit/_types.py:886`, `PositionBias.has_position_effect`, both-orders branch.
30
+
31
+ Replace that line with `return False` and **all 767 tests still pass**. No test
32
+ anywhere asserts that a both-orders frame with a real position effect is
33
+ detected.
34
+
35
+ What that branch controls, on the suite's own worst-case fixture (a judge that
36
+ picked the first-shown output on 100 of 100 flips):
37
+
38
+ ```
39
+ shipped: ... The flips have a direction, so this is position bias rather than
40
+ an unsteady judge. It reaches for whatever it sees first.
41
+
42
+ mutated: ... The flips split evenly across the two positions, so this is an
43
+ unsteady judge rather than a position-biased one. Inconsistency is
44
+ its own problem and does not become position bias without a direction.
45
+ ```
46
+
47
+ The 100% position-biased judge is described as splitting evenly. `audit` also
48
+ drops from a `warning` to an `info` for that data.
49
+
50
+ The reason no test catches it is `tests/test_judge.py:1243`:
51
+
52
+ ```python
53
+ assert "first" in r.summary().lower()
54
+ ```
55
+
56
+ Both versions of the summary contain the phrase *"went to whichever output was
57
+ shown first"* one sentence earlier, so the assertion holds either way. This is
58
+ the substring assertion matching unintended text.
59
+
60
+ Why it matters. Position bias in the both-orders design is the flagship check of
61
+ the flagship module. The shipped code is correct. I verified the branch by
62
+ hand. Nothing stops it regressing, and the one guard is decorative.
63
+
64
+ Confidence: certain. Fix: assert `r.has_position_effect` in
65
+ `test_a_judge_that_always_picks_the_first_output_scores_zero`, assert `not
66
+ r.has_position_effect` in `test_flips_that_are_noise_split_evenly_across_positions`,
67
+ and replace the `"first" in summary` assertions with the distinguishing clause
68
+ (`"reaches for whatever it sees first"` / `"unsteady judge"`). Under an hour.
69
+
70
+ ---
71
+
72
+ ### 2. A judge that prefers *short* answers is reported as length-biased toward long ones, three times in the same paragraph
73
+
74
+ `evalaudit/audit.py:757-762` (title and lead), `evalaudit/audit.py:802`
75
+ (`_length_flagged`), `evalaudit/_types.py:1064` (`LengthBias._preference`).
76
+
77
+ `_length_flagged` returns `not low <= 0.0 <= high`, which is true when the
78
+ interval sits entirely **below** zero as well as above it. The finding built on
79
+ top of it hard-codes the long-answer direction. Real output from a judge fitted
80
+ on a corpus where it systematically picks the shorter answer:
81
+
82
+ ```
83
+ severity: warning
84
+ TITLE : The judge is pulled by how long the answer is
85
+ DETAIL : A judge that rewards length ranks the wordier system higher whatever
86
+ it says, and it can do that while agreeing with humans on most pairs.
87
+ Judge preference against length: 283 characters of extra length
88
+ multiplies the odds the judge picks that answer by 0.13 (95% CI: 0.09
89
+ to 0.19, p=0.0000, 400 pairs). It picked the longer answer on 22.0% of
90
+ pairs. A positive coefficient here is not bias on its own, because
91
+ longer answers may simply be better. ...
92
+ ```
93
+
94
+ Coefficient is **-0.0072**. Three separate client-facing sentences assert a
95
+ long-answer preference (title, lead, and the "positive coefficient" caveat)
96
+ while the numbers in between say the opposite, including "picked the longer
97
+ answer on 22.0% of pairs" sitting directly against the title.
98
+
99
+ Why it matters. This is the prose that reaches a client, in the audit module
100
+ that is the deliverable. A sceptical reader who checks one number against one
101
+ sentence finds the contradiction inside the same paragraph.
102
+
103
+ Confidence: certain, demonstrated. Fix: branch title, lead and caveat on
104
+ `sign(coefficient)`. Half a day including tests, and the tests do not exist.
105
+ No fixture in the suite uses a short-preferring judge (`_length_flagged` can be
106
+ narrowed to `low > 0.0` and the whole suite passes).
107
+
108
+ ---
109
+
110
+ ### 3. `position_bias` reports an interval and a p-value that disagree about a half, which is the one thing `compare` is built not to do
111
+
112
+ `evalaudit/judge.py:498`. `_proportion` returns a Wilson score interval and an
113
+ exact binomial p-value for the same data. `PositionBias.has_position_effect`
114
+ (randomised branch) reads the **interval**; the summary prints the **p-value**
115
+ beside it.
116
+
117
+ The two are different procedures. Sweeping every table:
118
+
119
+ ```
120
+ 378 tables with n between 5 and 144 where the Wilson interval clears 0.5 and
121
+ the exact binomial p reported next to it does not reject (or vice versa)
122
+
123
+ k=0 n=5 : CI=(0.0000,0.4345) clears 50% exact p=0.0625 does not reject
124
+ k=1 n=8 : CI=(0.0224,0.4709) clears 50% exact p=0.0703 does not reject
125
+ k=3 n=14 : CI=(0.0757,0.4759) clears 50% exact p=0.0574 does not reject
126
+ ```
127
+
128
+ On those tables the summary reads *"The interval clears 50%, so the judge
129
+ favours whichever output it sees first (... exact binomial p=0.0625)"*, and
130
+ `audit` raises a position-bias warning. The disagreement dies out above about 144,
131
+ but judge-validation sets of 50 to 150 pairs are the normal case.
132
+
133
+ `compare` treats this exact property as load-bearing: `_paired_proportion_ci`
134
+ and `_score_diff_ci` both have long docstrings explaining that they invert the
135
+ test reported beside them, and `test_interval_agrees_with_test` and
136
+ `test_independent_interval_and_test_never_disagree` enforce it. `judge` does not
137
+ follow the same rule and does not say why.
138
+
139
+ Confidence: certain, demonstrated. Fix: either report Clopper-Pearson (which
140
+ inverts the exact test) or report the score p-value beside the Wilson interval,
141
+ and add the sweep test `compare` already has. A day.
142
+
143
+ ---
144
+
145
+ ### 4. `evalaudit.__version__` is wrong in the package that is on PyPI right now
146
+
147
+ `evalaudit/__init__.py:3` says `__version__ = "0.1.0"`. I downloaded both
148
+ published wheels:
149
+
150
+ ```
151
+ evalaudit 0.1.0 on PyPI -> __version__ = "0.1.0"
152
+ evalaudit 0.2.0 on PyPI -> __version__ = "0.1.0"
153
+ ```
154
+
155
+ Two releases report the same version. The repository has three different answers
156
+ in play: `pyproject.toml` at `HEAD` and at `origin/main` says `0.1.0`, the
157
+ un-pushed local commit `242e354` on `main` says `0.2.0`, and `__init__.py` says
158
+ `0.1.0` everywhere. Nothing keeps them in step and no test reads `__version__`.
159
+
160
+ Why it matters. It is the first thing anyone reproducing a consulting number
161
+ records, and it is live and wrong.
162
+
163
+ Confidence: certain, verified against PyPI. Fix: delete the literal and read it
164
+ with `importlib.metadata.version("evalaudit")`, or add a test asserting the two
165
+ agree. Under an hour.
166
+
167
+ ---
168
+
169
+ ### 5. Cohen's kappa can be computed from the wrong marginals and every test passes, because all three fixtures are balanced, including the one called "lopsided"
170
+
171
+ `evalaudit/agreement.py:538`:
172
+
173
+ ```python
174
+ expected = float(table.sum(axis=1) @ table.sum(axis=0)) / (n * n)
175
+ ```
176
+
177
+ Change `axis=0` to `axis=1`, squaring rater 1's marginals instead of crossing
178
+ the two raters', which is a textbook kappa error, and the **full 767-test suite
179
+ passes**.
180
+
181
+ The parametrised statsmodels check at `tests/test_agreement.py:1385` uses three
182
+ fixtures, ids `["binary", "three-way", "lopsided"]`:
183
+
184
+ | fixture | rater 1 marginals | rater 2 marginals | shipped κ | mutant κ |
185
+ |---|---|---|---|---|
186
+ | binary | `[5, 5]` | `[4, 6]` | 0.400000 | 0.400000 |
187
+ | three-way | `[4, 4, 4]` | `[3, 6, 3]` | 0.625000 | 0.625000 |
188
+ | lopsided | `[20, 20]` | `[20, 20]` | 0.500000 | 0.500000 |
189
+
190
+ Rater 1's counts are exactly uniform in all three, so the two expressions are
191
+ algebraically identical on every fixture. The fixture named "lopsided" is
192
+ perfectly balanced on **both** margins. The name claims the property the
193
+ fixture is there to test and does not have it. A 30/10 split catches the
194
+ mutation immediately (0.500000 vs 0.466667).
195
+
196
+ The other two kappa tests are blind for the same reason: `perfect_and_chance`
197
+ uses a symmetric table and 4000 coin flips.
198
+
199
+ Confidence: certain, demonstrated. Fix: add one unbalanced fixture. Ten minutes.
200
+
201
+ ---
202
+
203
+ ### 6. The audit titles an undefined agreement figure as "below the working threshold", and the detail immediately contradicts the title
204
+
205
+ `evalaudit/audit.py:588` (agreement) and `evalaudit/audit.py:662` (judge). Both
206
+ branches are entered by `if alpha != alpha or alpha < cfg.agreement_threshold`,
207
+ so a NaN alpha gets the below-threshold title and lead. Real output for thirty
208
+ items graded identically by two raters:
209
+
210
+ ```
211
+ TITLE : Rater agreement is below the working threshold
212
+ DETAIL: The grades under this eval reproduce less well than the 0.667 the report
213
+ is holding them to. Krippendorff's alpha is undefined (nominal, 30 of 30
214
+ items graded more than once). Every rating that could be compared was
215
+ identical, so there is no disagreement to divide by. ...
216
+ ```
217
+
218
+ The title and lead assert a comparison against 0.667 that was never made, and
219
+ the sentence after them says no number exists. `AgreementResult` goes to real
220
+ trouble to distinguish "no variance" from "too few items" and to say that
221
+ neither is perfect agreement; `audit` collapses both into a threshold claim.
222
+
223
+ Confidence: certain, demonstrated. Fix: a third branch with its own title
224
+ ("Agreement could not be measured"). An hour, plus tests.
225
+
226
+ ---
227
+
228
+ ### 7. The Wilcoxon path emits a zero-width 95% interval on n=0, which is the exact artifact the package's own docstring says it refuses to produce
229
+
230
+ `evalaudit/compare.py:402`. When every item ties:
231
+
232
+ ```
233
+ compare_paired([2.5,4.0,3.5,1.0,2.0], [2.5,4.0,3.5,1.0,2.0], method="wilcoxon")
234
+ -> "Difference 0.000 (95% CI: 0.000 to 0.000, paired, n=0). The interval
235
+ crosses zero, so the data cannot confirm that either system is better."
236
+ ```
237
+
238
+ `_paired_proportion_ci`'s docstring rejects the Wald interval partly because
239
+ "with no discordant pairs its standard error is zero, so it returns a single
240
+ point and claims two systems are identical on the strength of an empty table.
241
+ The score interval stays wide there, which is the honest answer." The McNemar
242
+ path has a dedicated `n_discordant == 0` branch in `ComparisonResult.summary()`
243
+ saying the table is empty. The Wilcoxon path sets `n_discordant=None`, misses
244
+ that branch, and prints the artifact.
245
+
246
+ `test_wilcoxon_counts_nothing_when_every_item_ties` asserts `n == 0`,
247
+ `p == 1.0`, `difference == 0.0` and never looks at the sentence.
248
+
249
+ Confidence: certain, demonstrated. Fix: a refusal branch in `summary()` keyed on
250
+ `n == 0`. An hour.
251
+
252
+ ---
253
+
254
+ ### 8. Two public interval methods have no test that checks a number
255
+
256
+ - `score_ci(method="t")` is **never called anywhere** in the suite, in
257
+ `examples/`, or in the README.
258
+ - `compare_independent(method="t")` (Welch) is called once, at
259
+ `tests/test_compare.py:564`, only to assert that a single-item group raises.
260
+
261
+ I mutated `evalaudit/scores.py:81` to `ddof=0` **and** swapped the t critical
262
+ value for the normal, together, and all 767 tests pass. I replaced Welch's
263
+ Satterthwaite degrees of freedom at `evalaudit/compare.py:667` with the pooled
264
+ `n1 + n2 - 2` and all 767 tests pass.
265
+
266
+ Both are correct as shipped. I checked them against
267
+ `scipy.stats.t.interval` and `ttest_ind(...).confidence_interval()` and they
268
+ match to the last bit. They are simply unguarded, and the README (line 257)
269
+ says "Every interval here is tested by simulation."
270
+
271
+ Confidence: certain. Fix: two coverage tests, or two reference comparisons
272
+ against scipy. An hour.
273
+
274
+ ---
275
+
276
+ ### 9. The bootstrap p-value can be halved and nothing fails
277
+
278
+ `evalaudit/compare.py:699`, `_bootstrap_p`. Drop the factor of two (turning a
279
+ two-sided p-value into a one-sided one) and all 767 tests pass. This is the
280
+ p-value on the **default** path for continuous scores, for both `compare_paired`
281
+ and `compare_independent`. The implementation is correct; nothing pins it.
282
+
283
+ Confidence: certain, demonstrated. Fix: assert the bootstrap p against a known
284
+ two-sided reference on one fixture. Twenty minutes.
285
+
286
+ ---
287
+
288
+ ### 10. The constrained-MLE cubic can lose a term and move reported bounds by more than a point, silently
289
+
290
+ `evalaudit/compare.py:562`, the Farrington-Manning cubic coefficient. Drop the
291
+ `delta * delta` term and all 767 tests pass. Effect on reported intervals:
292
+
293
+ ```
294
+ 45/50 vs 30/50 : shipped (+0.1365,+0.4551) broken (+0.1277,+0.4684) 1.33 points
295
+ 180/200 vs 150/200: shipped (+0.0771,+0.2241) broken (+0.0743,+0.2369) 1.28 points
296
+ 30/50 vs 20/50 : shipped (+0.0040,+0.3812) broken (+0.0040,+0.3705) 1.07 points
297
+ ```
298
+
299
+ The mutation survives because the tests pin only the property that survives it:
300
+ `test_independent_interval_and_test_never_disagree` checks that the interval and
301
+ the p-value agree about zero, and at δ=0 the dropped term vanishes. There is no
302
+ test against any external reference or published value for either score
303
+ interval.
304
+
305
+ I verified the shipped code is right (see "What came back clean"), so this is a
306
+ regression risk rather than a live error. But a 1.3-point shift is the size of
307
+ effect this package exists to adjudicate.
308
+
309
+ Confidence: certain that the gap exists; certain that the current code is
310
+ correct. Fix: pin `_constrained_rates` and `_tango_score` against a table of
311
+ brute-force constrained MLEs. Half a day.
312
+
313
+ ---
314
+
315
+ ### 11. The test named for the disagreement-fit rule does not test the rule
316
+
317
+ `tests/test_audit.py:560`, `test_length_bias_reads_the_disagreement_fit_when_humans_are_there`.
318
+ Its docstring says "The sharper of the two numbers is the one the verdict runs
319
+ on." It asserts `f.result.has_human` and `f.result.disagreement_ci_low > 0`,
320
+ both properties of the fixture, and nothing about which fit the verdict came
321
+ from. Replace `evalaudit/audit.py:800` with `if False:`, so the verdict always
322
+ uses the preference fit, and `tests/test_audit.py` passes clean.
323
+
324
+ The `length_biased` fixture makes both fits strongly positive, so the two
325
+ branches cannot be told apart on it.
326
+
327
+ Confidence: certain, demonstrated. Fix: a fixture where the preference fit is
328
+ positive and the disagreement fit is flat, and assert the finding is `info`.
329
+ Half a day.
330
+
331
+ ---
332
+
333
+ ### 12. `to_markdown` renders client-supplied names as markup; `to_html` escapes them
334
+
335
+ `evalaudit/_types.py:1526`. `_escape`'s docstring says system names "come out of
336
+ the client's spreadsheet and land in the report. They are data, not markup." The
337
+ HTML renderer honours that. The markdown renderer, which is the one the README
338
+ demonstrates, does not:
339
+
340
+ ```
341
+ report = audit(scores={"acme\n\n## Critical: all checks passed\n": new, "old": old})
342
+ report.to_markdown()
343
+
344
+ This is the margin between acme
345
+
346
+ ## Critical: all checks passed
347
+ and old, which is the number the claim rests on. Difference 11.7% ...
348
+ ```
349
+
350
+ A heading injected into a deliverable that says the opposite of the report.
351
+ Realistically this is a spreadsheet-hygiene problem rather than an attack, but
352
+ the asymmetry between the two renderers is unexplained.
353
+
354
+ Confidence: certain, demonstrated. Fix: escape or reject newlines and leading
355
+ `#`/`|` in names. An hour.
356
+
357
+ ---
358
+
359
+ ### 13. `KappaResult` is the only result type with a point estimate and no interval, and its `summary()` never says so
360
+
361
+ `evalaudit/_types.py:160`. Every other result carries `ci_low`/`ci_high`; this
362
+ one carries nothing. `AgreementResult` and `BTResult` both have whole paragraphs
363
+ explaining why an interval is missing when it is missing. `KappaResult` prints:
364
+
365
+ ```
366
+ Cohen's kappa 0.467 (8 items, 2 raters, 2 categories). Raters agreed on 75.0% ...
367
+ Krippendorff's alpha does not have that problem ... prefer rater_agreement.
368
+ ```
369
+
370
+ It steers the reader elsewhere, which is good, but it never states that the
371
+ number it just printed has no sampling error attached. Against the README's
372
+ "Never report a bare p-value. Always an effect size and an interval", this is
373
+ the one place the package hands over a naked estimate.
374
+
375
+ Confidence: certain. Fix: one sentence in `summary()`, or a bootstrap interval.
376
+ An hour for the sentence.
377
+
378
+ ---
379
+
380
+ ### 14. Public API surface: things that will read wrong once people depend on them
381
+
382
+ All confirmed by inspection of the signatures.
383
+
384
+ - **`position_bias(comparisons, seed=None)`**. `seed` is documented as unused
385
+ ("Accepted so the signature matches the rest of the package. Nothing here is
386
+ resampled."). Honest, and still a parameter that does nothing.
387
+ - **`position_bias` and `length_bias` take no `confidence`** while every other
388
+ public function does, and both return dataclasses carrying a `confidence`
389
+ field. The interval level is hard-coded twice (`judge.py:498` `_wilson(k, n,
390
+ 0.95)`, `judge.py` `norm.ppf(0.975)`). A caller who wants a 90% or 99%
391
+ interval cannot have one, and a `PositionBias` constructed with
392
+ `confidence=0.99` would print "99% CI" over a 95% interval.
393
+ - **`audit(comparisons=...)` and `bradley_terry(comparisons=...)` take
394
+ incompatible frames** under the same parameter name: `pair_id/option_a/
395
+ option_b/winner` versus `item_id/model_a/model_b/winner`. Passing a
396
+ leaderboard frame to `audit` raises a message that names the expected columns
397
+ but never mentions `bradley_terry`.
398
+ - **`detectable_effect(n, baseline=0.5, ...)`**. The second positional argument
399
+ does nothing in the default (`paired=True`) mode, and it sits where a reader
400
+ who saw `detectable_effect(500, discordance_rate=12/500)` in the README would
401
+ naturally put the discordance rate. `detectable_effect(500, 0.3)` silently
402
+ means "baseline 0.3, discordance assumed".
403
+
404
+ Confidence: certain. Fix: add `confidence` to the two judge functions, rename
405
+ `audit(comparisons=)` to `judgements=` (or make the error message point at
406
+ `bradley_terry`), and make `baseline` keyword-only. Half a day, and the window
407
+ for it is closing since 0.1.0 and 0.2.0 are both on PyPI.
408
+
409
+ ---
410
+
411
+ ### 15. Two commit messages do not describe their contents
412
+
413
+ - `d38c7a3` **"point at the new account, bump to 0.2.0"**. `pyproject.toml` at
414
+ that commit still says `version = "0.1.0"`. The bump landed later, in the
415
+ un-pushed local commit `242e354` ("Version Update"). `origin/main` points at
416
+ `d38c7a3`, so the message is wrong on the published branch.
417
+ - `a488aa4` **"add audit module: the report the other five modules exist to
418
+ produce"** contains a single change, deleting `CLAUDE.md`. The commit that
419
+ actually adds the audit module is `0500f53`, which carries the identical
420
+ message.
421
+
422
+ Nothing secret is committed. I regex-scanned the full history for keys, tokens,
423
+ passwords and private-key headers and found none.
424
+
425
+ Confidence: certain. Fix: nothing to do on published history; worth noting.
426
+
427
+ ---
428
+
429
+ ### 16. Smaller documentation drift
430
+
431
+ - **README line 257: "Every interval here is tested by simulation."** Not true
432
+ of `score_ci(method="t")`, `compare_independent(method="t")`,
433
+ `compare_independent(method="score")`, `compare_independent(method="bootstrap")`,
434
+ the Wilson interval in `position_bias`, or the Wald interval in `length_bias`.
435
+ Four of those six are validated some other way (exhaustive test/interval
436
+ agreement, or a statsmodels reference), which is arguably better than
437
+ simulation. The sentence as written is still checkable and false. Six of the
438
+ package's intervals do have real simulation coverage tests.
439
+ - **README line 139: "It runs five of these checks"** against
440
+ `evalaudit/audit.py:9` "Seven checks run". Both are defensible (five modules,
441
+ seven checks) and they are eleven files apart saying different numbers.
442
+ - **`evalaudit/power.py:130`**, docstring example:
443
+ `>>> detectable_effect(220, discordance_rate=0.3).difference` → `0.10289...`.
444
+ The actual value is `0.10290198155585326`. As a doctest with ellipsis it would
445
+ fail. Doctests are not run: `pyproject.toml` sets `testpaths = ["tests"]` with
446
+ no `--doctest-modules`, so the four `Examples` blocks in `scores.py`,
447
+ `compare.py` and `power.py` are never executed. The other three are accurate.
448
+ - **`PowerResult._reach` prints half an item** on an odd independent n:
449
+ `detectable_effect(101, paired=False)` → "This eval ran 101 items, 50.5 in
450
+ each group."
451
+ - **`pytest-cov` is declared in `[dev]` and never used.** No `--cov` flag in
452
+ `pyproject.toml` or the workflow, no coverage step in CI.
453
+
454
+ Confidence: certain, all verified. Fix: an hour for the lot.
455
+
456
+ ---
457
+
458
+ ## Suspected, not proven
459
+
460
+ ### A. `_fit` in `pairwise.py` has no convergence check on the primary fit
461
+
462
+ `evalaudit/pairwise.py`. Newton runs to `_MAX_NEWTON = 100` and breaks on the
463
+ gradient tolerance. If it never reaches tolerance it returns whatever it reached,
464
+ with no flag and no note. The docstring justifies the cap for resamples ("so a
465
+ resample that is nearly separable cannot spin") but the same code path fits the
466
+ full data.
467
+
468
+ Mitigating: Ford's condition is checked before the fit, so a finite maximum is
469
+ guaranteed to exist, and the regularised Hessian is well conditioned. I could
470
+ not construct a case that fails to converge in 100 steps. Newton without a line
471
+ search is not globally convergent on this likelihood in general, so I cannot
472
+ rule it out either. `judge.py:_fit_logistic` handles the same risk properly. It
473
+ returns `_NOTE_NO_CONVERGENCE` on the `for/else`. `pairwise._fit` does not.
474
+
475
+ Confidence: low that it bites; certain that the asymmetry with `judge.py` is
476
+ real. Fix: a `for/else` and a `converged` flag on `BTResult`. Half a day.
477
+
478
+ ### B. The bootstrap in `pairwise` resamples comparisons, not items
479
+
480
+ Documented honestly in the docstring ("on a design where one prompt carries many
481
+ comparisons the interval will run slightly narrow"). I did not measure how
482
+ narrow, and the coverage test uses one comparison per row so it cannot show it.
483
+ A leaderboard where 20 model pairs are judged on the same 100 prompts is the
484
+ normal shape and would be understated. Whether the understatement is 1% or 20%
485
+ of the width, I do not know.
486
+
487
+ ### C. The coverage bands are wider than they need to be
488
+
489
+ `test_bootstrap_coverage` in `test_scores.py` uses 400 trials and asserts
490
+ `0.91 <= rate <= 0.99`. Four standard errors either side. An interval built at
491
+ 98.5% nominal instead of 95% would pass. `test_wilson_coverage` (1000 trials,
492
+ 0.92 to 0.98) is about three standard errors and is tighter than it needs to be to
493
+ catch the mutations I tried. I killed both Wilson mutations easily. I did not
494
+ find a plausible implementation error that squeaks through any of these bands,
495
+ so I am listing this as a smell rather than a finding. The `pairwise` and
496
+ `compare` bands come with arithmetic in their docstrings justifying the width,
497
+ which is the right way to do it; `test_scores.py` does not.
498
+
499
+ ---
500
+
501
+ ## What came back clean
502
+
503
+ I looked hard at these and found nothing. Saying so is part of the report.
504
+
505
+ **Krippendorff's alpha (`agreement.py`), the strongest part of the package.**
506
+ Checked three independent ways: against the `krippendorff` package on PyPI
507
+ across fifteen parametrised settings including gapped domains, continuous
508
+ ratings and 50%-missing designs; against Krippendorff's own published worked
509
+ examples (the four-observer 0.743/0.815/0.849 and three-observer
510
+ 0.691358/0.810845 figures both reproduce); and against a slow reference written
511
+ from the definition inside the test file, which is itself validated against the
512
+ reference package first. I re-derived the nominal, interval and ordinal
513
+ coincidence formulas by hand and they match the code, including the ordinal
514
+ metric's half-of-each-end term. Every mutation I tried was killed: `n` for
515
+ `n-1`, dropping the ordinal end term, `sizes` for `sizes-1`, the usable-share
516
+ floor, the two-item floor, the dropout sort direction, the item-table
517
+ normalisation. The `_MIN_USABLE_SHARE` refusal and the leave-one-out noise
518
+ guard are both tested in both directions.
519
+
520
+ **The two score intervals are formula-correct, and I verified them the hard
521
+ way.** For Tango's paired interval I solved the constrained multinomial
522
+ likelihood numerically and compared against the quadratic root the code uses.
523
+ They agree to 3e-9 across eight tables. For the independent interval I did the
524
+ same for the Farrington-Manning cubic:
525
+
526
+ ```
527
+ case true (q1,q2) evalaudit statsmodels
528
+ 45/50 30/50 d=+0.45 (0.930774,0.480774) (0.930774,0.480774) (0.858852,0.408852)
529
+ 9/20 3/20 d=+0.40 (0.520234,0.120234) (0.520234,0.120234) (0.488953,0.088953)
530
+ ```
531
+
532
+ evalaudit matches the true constrained MLE exactly on every case I tried;
533
+ `statsmodels.score_test_proportions_2indep` does not. I also confirmed the
534
+ δ=0 collapse in both cases. Tango's statistic reduces to `(b-c)/sqrt(b+c)` and
535
+ the independent one to the pooled two-proportion z, so the stated "the interval
536
+ inverts the test" property holds in `compare` as claimed.
537
+
538
+ **Wilcoxon.** `_signed_rank_counts` is a correct subset-sum DP; `_signed_rank_trim`
539
+ reproduces the published two-sided α=0.05 critical value table for n=6..18; the
540
+ tie-corrected variance matches scipy's; the Hodges-Lehmann interval indexing is
541
+ right. The refusal below three distinct non-zero differences, with the 41%
542
+ contradiction rate quoted in the error message, is a genuinely good piece of
543
+ design. Mutations to the trim and the tie correction were killed.
544
+
545
+ **`power.py`.** I re-derived the quadratic in `_paired_difference` from
546
+ `_power_paired` and it matches the docstring and the code, discriminant
547
+ included. `_paired_size` is Connor's published form. `_independent_size` is the
548
+ standard pooled two-proportion formula. Every mutation I tried was killed:
549
+ dropping the z_β term, dropping z_β² from the denominator, one-sided alpha,
550
+ unpooled null variance, changing the default discordance rate, the per-group
551
+ doubling. The roundtrip between the two entry points is tested from both
552
+ directions.
553
+
554
+ **`pairwise.py`.** Ford's condition, the disconnected-groups check, the
555
+ undefeated/winless refusal, the Elo affine transform, the reference-shifted
556
+ intervals, the tie policies, the resample usability filter and the coverage
557
+ simulation are all tested with teeth. Mutations killed: Newton step cap, Newton
558
+ tolerance, `log(10)` → `log(2)`, tie credit 0.5 → 0.4, dropping the reference
559
+ shift, dropping the strong-connectivity check, the usable-share floor. The
560
+ `ties="davidson"` `NotImplementedError` is a good call.
561
+
562
+ **`test_readme.py` is a real test.** It executes every python block that has an
563
+ output block, splits the shown output on `[...]`, and requires each fragment to
564
+ appear verbatim with whitespace collapsed. It guards its own regex with a block
565
+ count. I re-ran the pairwise README example independently and confirmed the
566
+ claim "every one of those five is a pair containing the weakest model". All
567
+ five separable pairs are `mN` vs `m6`.
568
+
569
+ **`examples/interval_method_study.py` reproduces its recorded output exactly**,
570
+ to the last digit of all four tables, including the coverage figures its
571
+ docstring quotes.
572
+
573
+ **Packaging and CI.** Dependencies are `numpy`, `scipy`, `pandas`, all three
574
+ imported, nothing else imported that is not stdlib, nothing declared and unused
575
+ except `pytest-cov`. `requires-python = ">=3.9"` is honest: every module has
576
+ `from __future__ import annotations`, there is no `match`, no PEP 604 union in a
577
+ runtime position. The `choix>=0.4; python_version>='3.10'` marker with the
578
+ documented fallback is a careful piece of work. GitHub Actions shows the
579
+ `tests` workflow green on `main` across 3.9/3.11/3.12 for the last six runs, so
580
+ the 3.9 job really does resolve `krippendorff` (0.8.1 is the last release
581
+ supporting 3.9) and really does run. The published sdist contains exactly what
582
+ it should: source, tests, examples, README, LICENSE, workflow. No stray data
583
+ files, no notebooks, no secrets.
584
+
585
+ **Prose style.** No em-dashes or en-dashes anywhere in `evalaudit/` or the
586
+ README. No "not X, but Y" constructions in the shipped summaries. No leaked
587
+ `repr` output in any summary I generated. `!r` is used deliberately and only on
588
+ slice labels, which is the right call. The `_escape` HTML path is correct.
589
+
590
+ **Design invariants.** All thirteen return types are `@dataclass(frozen=True)`.
591
+ All of them have `summary()` except `AuditConfig`, which is a config rather than
592
+ a result. No public function returns a bare p-value; every p-value reported has
593
+ an effect size and (except for `KappaResult`, finding 13) an interval beside it.
594
+
595
+ ---
596
+
597
+ ## What I could not check
598
+
599
+ 1. **Tango (1998) and Mee (1984) against the actual papers.** I do not have
600
+ them. I verified both implementations against a brute-force numerical
601
+ maximisation of the same constrained likelihood, which establishes the
602
+ arithmetic is right, and I verified the δ=0 collapse the docstrings claim.
603
+ What I cannot confirm is that the citation is the right attribution for the
604
+ construction, or that the docstring's claim about
605
+ Miettinen-Nurminen, "Measured over 9213 tables the correction produced 4
606
+ disagreements where this version produces none ... about 0.3 to 1 percent
607
+ wider", reproduces. That measurement is not in the repository, and nothing
608
+ in `examples/` or `tests/` regenerates it. The comparable claim in
609
+ `compare.py`'s Wilcoxon docstring ("Measured over 600 binary samples it
610
+ reported a bound of exactly zero while the p-value rejected on 41% of them")
611
+ is in the same position. `examples/interval_method_study.py` is the model for
612
+ how to make a claim like that checkable, and neither of these two follows it.
613
+
614
+ 2. **Connor (1987) against the paper.** Same situation. The formula matches the
615
+ version reproduced in every secondary source I know, and the algebra of the
616
+ inverse is self-consistent, but I did not read Connor.
617
+
618
+ 3. **The power docstring's simulation claim.** "Measured over 200,000 simulated
619
+ evals the shipped test delivers about 0.784 where this formula promises
620
+ 0.800." Not reproduced in the repository and not cheap to rerun. The
621
+ `_floor_sentence()` that reaches the client rests on it.
622
+
623
+ 4. **The `pairwise` bootstrap's behaviour on clustered designs** (suspicion B).
624
+ Measuring it properly needs a simulation with repeated prompts, which I did
625
+ not build.
626
+
627
+ 5. **Real client data.** Every judgement about the prose is on synthetic
628
+ fixtures. I have not seen a real ratings frame go through `audit`.
629
+
630
+ 6. **Whether the `promptstats` link in the README's Related section still
631
+ describes that package accurately.** I did not fetch it.
632
+
633
+ ---
634
+
635
+ ## The three I would fix first
636
+
637
+ **1. Finding 2, the length-bias direction bug.** It is the only item on this
638
+ list that is producing a wrong sentence in a deliverable *right now*, on data a
639
+ real client could plausibly have. A judge that prefers terse answers is not
640
+ exotic; short-answer preference is a documented LLM-judge failure mode. The
641
+ report calls it the opposite, three times, in one paragraph, with its own
642
+ numbers sitting next to the contradiction. Everything else here is either a
643
+ latent regression risk or a metadata problem. This one is the failure a
644
+ sceptical reader actually catches, and catching it costs them nothing. They
645
+ just read the paragraph. Fix the sign branch, and add the short-preferring
646
+ fixture the suite has never had.
647
+
648
+ **2. Finding 1, the untestable position-bias verdict.** Not because the code is
649
+ wrong (it is not) but because of what the hole says about the rest. The both-
650
+ orders branch is the single most distinctive check in the package, the reason
651
+ someone would choose `evalaudit` over `scipy`, and the assertion guarding it is
652
+ matching the word "first" in a sentence that always contains it. The same
653
+ pattern is what let finding 5 hide for three commits. I would fix this one and
654
+ then re-read every `assert <string> in summary` in the suite with the same
655
+ suspicion. There are enough of them that at least one more is probably
656
+ decorative. Two hours of work that changes what the test suite is worth.
657
+
658
+ **3. Finding 4, the version.** Trivially small and disproportionately
659
+ expensive. The pitch is "the numbers hold up when someone checks them", and the
660
+ first check anyone runs when reproducing a consulting result is
661
+ `evalaudit.__version__`. It answers `0.1.0` from a package that says `0.2.0` on
662
+ its label. There is no argument to be had about severity, no statistics to
663
+ discuss, and it takes twenty minutes. Read it from `importlib.metadata` and add
664
+ the one test that would have caught it.
665
+
666
+ Finding 3 (the Wilson/exact mismatch) is the next one after these, and it is a
667
+ close fourth. It is the only finding where the shipped arithmetic is
668
+ genuinely inconsistent with the package's own stated rule rather than merely
669
+ untested. I left it out of the three because it needs a design decision
670
+ (Clopper-Pearson or a score p-value) rather than a fix, and because the tables
671
+ where the two disagree are concentrated at n below 145.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: evalaudit
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Statistical validity checks for human-graded AI evaluations. Rater agreement, judge validation, and whether your result holds up.
5
5
  Project-URL: Homepage, https://github.com/mokayahezron/evalaudit
6
6
  Project-URL: Issues, https://github.com/mokayahezron/evalaudit/issues