blind-panel 0.1.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 iSimplifyMe
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.
package/README.md ADDED
@@ -0,0 +1,137 @@
1
+ # blind-panel
2
+
3
+ Blind pairwise evaluation — seeded blinding, position-bias detection, and
4
+ inter-rater agreement.
5
+
6
+ **The win rate is the number everyone quotes and the number that means least.**
7
+
8
+ ```bash
9
+ npx blind-panel prepare --candidates=a,b --items=one,two,three --out=./run --seed=r1
10
+ # judges see run/manifest.json — never run/key.json
11
+ npx blind-panel tally --dir=./run --verdicts=./verdicts.json
12
+ ```
13
+
14
+ Zero dependencies. MIT.
15
+
16
+ ## Why
17
+
18
+ When you put candidates in front of a panel — human or model — and ask which is
19
+ better, two numbers decide whether the answer means anything, and neither is
20
+ usually reported:
21
+
22
+ **Position bias.** If judges chose "left" far from half the time, they were
23
+ responding to position rather than content, and every win rate is void. Trivial
24
+ to compute. Almost never computed.
25
+
26
+ **Inter-rater agreement.** Near chance means the panel detected no shared signal,
27
+ so the win rate is noise however decisive it looks. High agreement means *either*
28
+ a real difference *or* a bias the judges share — this statistic cannot separate
29
+ those, and the tool says so rather than letting the ambiguity pass for rigour.
30
+
31
+ Either condition exits non-zero, because a run that failed them has not measured
32
+ anything and should not be reported as though it had.
33
+
34
+ ## Confidence you can put in front of a client
35
+
36
+ Every win rate ships with a **Wilson score interval** and an **exact binomial
37
+ test**, and a verdict that requires both.
38
+
39
+ The textbook `p ± z·sqrt(p(1-p)/n)` fails exactly where evaluation panels live —
40
+ small n, lopsided results. At 0 or n wins it collapses to zero width, reporting
41
+ "100%, ±0" from four comparisons. It emits bounds below 0 and above 1, which are
42
+ not probabilities. Its real coverage below n≈40 is well under the nominal 95%, so
43
+ it overstates confidence precisely where a reader needs the opposite.
44
+
45
+ Wilson is bounded in [0,1] by construction and behaves at the extremes. It is
46
+ asymmetric, so both bounds are reported rather than a single ± that would discard
47
+ the asymmetry.
48
+
49
+ Wilson is still an approximation, and at very small n it runs slightly liberal.
50
+ A perfect 4-of-4 gives a Wilson lower bound of 0.5101 — excluding 0.5, so it
51
+ would be called a win — while the exact two-sided p is 0.125. Four straight wins
52
+ is what a fair coin does one time in eight. **A directional verdict therefore
53
+ requires the interval to exclude 0.5 AND the exact test to reach α.**
54
+
55
+ | wins/n | win rate | Wilson 95% | exact p | verdict |
56
+ |---|---|---|---|---|
57
+ | 4/4 | 1.000 | [0.510, 1.000] | 0.125 | inconclusive |
58
+ | 8/11 | 0.727 | [0.434, 0.902] | 0.227 | inconclusive |
59
+ | 80/110 | 0.727 | [0.637, 0.802] | 0.000002 | **better** |
60
+ | 55/100 | 0.550 | [0.452, 0.644] | 0.368 | inconclusive |
61
+ | 0/20 | 0.000 | [0.000, 0.161] | 0.000002 | **worse** |
62
+
63
+ 8/11 and 80/110 are the same 72.7%. Only one of them is a result.
64
+
65
+ ## What it does not do
66
+
67
+ **It does not judge.** It handles blinding, randomisation, unblinding and
68
+ statistics; who or what looks at the candidates is yours. That separation is the
69
+ point: anyone holding the seed can re-derive the assignment and check the
70
+ arithmetic instead of trusting your summary.
71
+
72
+ ## Balanced by construction
73
+
74
+ Side assignment is not an independent coin flip per pair. With six items, pure
75
+ random puts every one on the same side about 3% of the time — and when it does,
76
+ the candidate's identity is perfectly confounded with position, which is exactly
77
+ the failure this package exists to detect.
78
+
79
+ Instead each candidate gets an equal number of left and right placements, shuffled
80
+ by a seeded Fisher-Yates. Balance is guaranteed; the individual assignment is
81
+ still unpredictable.
82
+
83
+ *(This was found by the first smoke run of this package producing
84
+ `candidateLeft: 6, candidateRight: 0`. It is now a regression test.)*
85
+
86
+ ## Usage
87
+
88
+ ### Explicit items
89
+
90
+ ```bash
91
+ blind-panel prepare --candidates=modelA,modelB --items=q1,q2,q3 --out=./run --seed=run-1
92
+ ```
93
+
94
+ ### Directory mode
95
+
96
+ Candidate names come from directory basenames; items are the files present in
97
+ every candidate *and* the reference. Anything missing from a candidate is dropped
98
+ **loudly** — comparing a candidate on an item another candidate lacks would weight
99
+ the panel silently.
100
+
101
+ ```bash
102
+ blind-panel prepare --candidates=./out/a,./out/b --reference=./out/ref --out=./run
103
+ ```
104
+
105
+ ### Verdicts
106
+
107
+ ```json
108
+ [
109
+ { "pairId": "modelA--q1", "judge": "alice", "choice": "left" },
110
+ { "pairId": "modelA--q1", "judge": "bob", "choice": "right" }
111
+ ]
112
+ ```
113
+
114
+ ```bash
115
+ blind-panel tally --dir=./run --verdicts=./verdicts.json
116
+ ```
117
+
118
+ Exit 0 means the run is usable. Exit 1 means it is not, and says why.
119
+
120
+ ### As a library
121
+
122
+ ```js
123
+ import { buildPairs, tally, problemsWith, sideBalance } from 'blind-panel';
124
+ ```
125
+
126
+ ## Origin
127
+
128
+ Extracted from a project that needed to judge three candidate visual designs and
129
+ wanted the judging to be worth something. The methodology owes a debt to
130
+ [mshumer/Claude-of-Duty](https://github.com/mshumer/Claude-of-Duty), which ran
131
+ eleven critics in blind A/B against real reference frames and published the
132
+ result honestly — but shipped no tooling for it. This is that procedure made
133
+ executable and checkable.
134
+
135
+ ## License
136
+
137
+ MIT © iSimplifyMe
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * blind-panel — blind pairwise evaluation.
4
+ *
5
+ * blind-panel prepare --candidates=a,b --items=hero,wide --out=DIR [--seed=S]
6
+ * blind-panel prepare --candidates=dirA,dirB --reference=dirRef --out=DIR
7
+ * blind-panel tally --dir=DIR --verdicts=verdicts.json
8
+ *
9
+ * PREPARE writes manifest.json (give this to judges) and key.json (withhold it
10
+ * until verdicts are in).
11
+ *
12
+ * TALLY unblinds and reports win rate with a confidence interval, a
13
+ * position-bias check, and inter-rater agreement. It exits non-zero if either
14
+ * check fires, because a run with position bias or chance-level agreement has
15
+ * not measured anything and should not be reported as though it had.
16
+ */
17
+ import { readFileSync, writeFileSync, readdirSync, mkdirSync, existsSync, statSync } from 'node:fs';
18
+ import { resolve, join, basename } from 'node:path';
19
+ import { buildPairs, manifestFor, keyFor, tally, problemsWith, sideBalance } from '../src/blind.js';
20
+
21
+ const argv = process.argv.slice(2);
22
+ const cmd = argv[0];
23
+ const args = Object.fromEntries(argv.slice(1).map((a) => {
24
+ const m = a.match(/^--([^=]+)(?:=(.*))?$/); return m ? [m[1], m[2] ?? true] : [a, true];
25
+ }));
26
+
27
+ const die = (msg, code = 2) => { console.error(msg); process.exit(code); };
28
+ const list = (v) => String(v ?? '').split(',').map((s) => s.trim()).filter(Boolean);
29
+
30
+ const USAGE = `blind-panel — blind pairwise evaluation
31
+
32
+ blind-panel prepare --candidates=a,b --items=x,y --out=DIR [--seed=S]
33
+ blind-panel prepare --candidates=dirA,dirB --reference=dirRef --out=DIR [--seed=S]
34
+ blind-panel tally --dir=DIR --verdicts=verdicts.json
35
+
36
+ verdicts.json is [{ "pairId": "...", "judge": "...", "choice": "left"|"right" }]`;
37
+
38
+ // ---------------------------------------------------------------- prepare
39
+ if (cmd === 'prepare') {
40
+ const outDir = resolve(args.out ?? './blind-run');
41
+ const seed = String(args.seed ?? 'blind-panel');
42
+ const rawCandidates = list(args.candidates);
43
+ if (!rawCandidates.length) die('--candidates is required\n\n' + USAGE);
44
+
45
+ let names = rawCandidates;
46
+ let items = list(args.items);
47
+ let dirMode = false;
48
+
49
+ // Directory mode: candidate names come from directory basenames, and items
50
+ // are the files present in EVERY candidate as well as the reference.
51
+ if (args.reference) {
52
+ dirMode = true;
53
+ const refDir = resolve(args.reference);
54
+ const candDirs = rawCandidates.map((d) => resolve(d));
55
+ for (const d of [...candDirs, refDir]) {
56
+ if (!existsSync(d) || !statSync(d).isDirectory()) die(`not a directory: ${d}`);
57
+ }
58
+ names = candDirs.map((d) => basename(d));
59
+ const refItems = readdirSync(refDir).filter((f) => !f.startsWith('.')).sort();
60
+ const common = refItems.filter((f) => candDirs.every((d) => existsSync(join(d, f))));
61
+ const dropped = refItems.filter((f) => !common.includes(f));
62
+
63
+ if (!common.length) die('no filenames common to the reference and every candidate');
64
+ items = common;
65
+
66
+ // Loud, never silent. Comparing a candidate on an item another candidate
67
+ // lacks would weight the panel without anyone noticing.
68
+ if (dropped.length) {
69
+ console.error(`NOTE: dropped ${dropped.length} item(s) not present in every candidate: ${dropped.join(', ')}`);
70
+ }
71
+ }
72
+
73
+ if (!items.length) die('--items is required unless --reference is given\n\n' + USAGE);
74
+
75
+ const pairs = buildPairs(names, items, seed);
76
+ mkdirSync(outDir, { recursive: true });
77
+ writeFileSync(join(outDir, 'manifest.json'), JSON.stringify(manifestFor(pairs), null, 2));
78
+ writeFileSync(join(outDir, 'key.json'), JSON.stringify(keyFor(pairs, seed), null, 2));
79
+
80
+ console.log(JSON.stringify({
81
+ out: outDir,
82
+ mode: dirMode ? 'directory' : 'explicit',
83
+ candidates: names,
84
+ items,
85
+ pairs: pairs.length,
86
+ sideBalance: sideBalance(pairs),
87
+ seed,
88
+ note: 'Judges get manifest.json. Withhold key.json until verdicts are in.',
89
+ }, null, 2));
90
+ process.exit(0);
91
+ }
92
+
93
+ // ---------------------------------------------------------------- tally
94
+ if (cmd === 'tally') {
95
+ const dir = args.dir ? resolve(args.dir) : null;
96
+ const keyPath = args.key ? resolve(args.key) : (dir ? join(dir, 'key.json') : null);
97
+ if (!keyPath || !existsSync(keyPath)) die('--key=key.json (or --dir=DIR) is required\n\n' + USAGE);
98
+ if (!args.verdicts) die('--verdicts=verdicts.json is required\n\n' + USAGE);
99
+
100
+ const key = JSON.parse(readFileSync(keyPath, 'utf8'));
101
+ const verdicts = JSON.parse(readFileSync(resolve(args.verdicts), 'utf8'));
102
+ if (!Array.isArray(verdicts)) die('verdicts.json must be an array of {pairId, judge, choice}');
103
+
104
+ const result = tally(key, verdicts);
105
+ console.log(JSON.stringify(result, null, 2));
106
+
107
+ const problems = problemsWith(result);
108
+ if (problems.length) {
109
+ console.error('\nPROBLEMS:\n - ' + problems.join('\n - '));
110
+ process.exit(1);
111
+ }
112
+ console.error('\nOK: no position bias, agreement above chance.');
113
+ process.exit(0);
114
+ }
115
+
116
+ die(USAGE);
package/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "blind-panel",
3
+ "version": "0.1.0",
4
+ "description": "Blind pairwise evaluation — seeded blinding, position-bias detection, and inter-rater agreement. The win rate is the number that means least.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "iSimplifyMe",
8
+ "main": "src/blind.js",
9
+ "exports": { ".": "./src/blind.js" },
10
+ "bin": { "blind-panel": "bin/blind-panel.mjs" },
11
+ "files": ["src", "bin", "README.md", "LICENSE"],
12
+ "engines": { "node": ">=18" },
13
+ "scripts": { "test": "node --test test/*.test.js" },
14
+ "keywords": ["blind", "evaluation", "ab-testing", "inter-rater", "agreement", "position-bias", "judging", "llm-judge"],
15
+ "repository": { "type": "git", "url": "git+https://github.com/iSimplifyMe/blind-panel.git" },
16
+ "homepage": "https://github.com/iSimplifyMe/blind-panel"
17
+ }
package/src/blind.js ADDED
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Blind pairwise evaluation.
3
+ *
4
+ * WHAT THIS IS FOR. When you put candidates in front of a panel — human or model
5
+ * — and ask which is better, the number everyone quotes is the win rate, and it
6
+ * is the number that means least. Two others decide whether it means anything at
7
+ * all:
8
+ *
9
+ * POSITION BIAS. If judges chose "left" far from half the time, they were
10
+ * responding to position rather than content, and every win rate is void.
11
+ * Trivial to compute. Almost never reported.
12
+ *
13
+ * INTER-RATER AGREEMENT. Near chance means the panel detected no shared
14
+ * signal, so the win rate is noise however decisive it looks. High agreement
15
+ * means EITHER a real difference OR a bias the judges share — this statistic
16
+ * cannot separate those, and pretending otherwise is the standard way blind
17
+ * panels get oversold.
18
+ *
19
+ * WHAT THIS IS NOT. It does not judge. It handles blinding, randomisation,
20
+ * unblinding and statistics; who or what looks at the candidates is yours to
21
+ * decide. That separation is what makes a published result auditable — anyone
22
+ * holding the seed re-derives the assignment and checks the arithmetic instead
23
+ * of trusting it.
24
+ *
25
+ * Zero dependencies. Everything here is pure and seeded.
26
+ */
27
+
28
+ // ---------------------------------------------------------------- seeded rng
29
+
30
+ function xmur3(str) {
31
+ let h = 1779033703 ^ str.length;
32
+ for (let i = 0; i < str.length; i++) {
33
+ h = Math.imul(h ^ str.charCodeAt(i), 3432918353);
34
+ h = (h << 13) | (h >>> 19);
35
+ }
36
+ return function () {
37
+ h = Math.imul(h ^ (h >>> 16), 2246822507);
38
+ h = Math.imul(h ^ (h >>> 13), 3266489909);
39
+ return (h ^= h >>> 16) >>> 0;
40
+ };
41
+ }
42
+
43
+ function sfc32(a, b, c, d) {
44
+ return function () {
45
+ a |= 0; b |= 0; c |= 0; d |= 0;
46
+ const t = (((a + b) | 0) + d) | 0;
47
+ d = (d + 1) | 0;
48
+ a = b ^ (b >>> 9);
49
+ b = (c + (c << 3)) | 0;
50
+ c = (c << 21) | (c >>> 11);
51
+ c = (c + t) | 0;
52
+ return (t >>> 0) / 4294967296;
53
+ };
54
+ }
55
+
56
+ /** Deterministic PRNG from a string seed. Same seed, same sequence, anywhere. */
57
+ export function makeRng(seed = 'blind-panel') {
58
+ const h = xmur3(String(seed));
59
+ return sfc32(h(), h(), h(), h());
60
+ }
61
+
62
+ // ---------------------------------------------------------------- blinding
63
+
64
+ /**
65
+ * Build the blinded pair list: one pair per (candidate, item), candidate against
66
+ * the reference for that item. Which side the candidate lands on comes from a
67
+ * seeded stream — random to the judge, reproducible to an auditor.
68
+ *
69
+ * Inputs are SORTED before the stream is consumed. Without that, "the same seed
70
+ * reproduces the same blinding" would silently depend on directory listing order
71
+ * or object key order, which is not a guarantee at all.
72
+ */
73
+ export function buildPairs(candidates, items, seed = 'blind-panel') {
74
+ const sortedItems = [...items].sort();
75
+ const pairs = [];
76
+
77
+ for (const candidate of [...candidates].sort()) {
78
+ // BALANCED BY CONSTRUCTION, not by hoping a coin flip evens out.
79
+ //
80
+ // Independent coin flips per pair look correct and are not. With six items
81
+ // the probability of landing every one on the same side is 1/32, and when it
82
+ // happens the candidate's identity is perfectly confounded with position —
83
+ // which is precisely the failure this tool exists to detect. It was hit on
84
+ // the first smoke run of this package.
85
+ //
86
+ // Instead: build a list that is half "left" and half "right" (odd counts
87
+ // take the extra side from the stream, so balance stays even ACROSS
88
+ // candidates rather than always favouring one side), then shuffle it. Every
89
+ // candidate now gets the same number of left and right placements, and the
90
+ // judge still cannot predict any individual pair.
91
+ const rng = makeRng(`${seed}::sides::${candidate}`);
92
+ const n = sortedItems.length;
93
+ const half = Math.floor(n / 2);
94
+ const sides = [
95
+ ...Array(half).fill('left'),
96
+ ...Array(half).fill('right'),
97
+ ...(n % 2 ? [rng() < 0.5 ? 'left' : 'right'] : []),
98
+ ];
99
+
100
+ // Fisher-Yates, seeded.
101
+ for (let i = sides.length - 1; i > 0; i--) {
102
+ const j = Math.floor(rng() * (i + 1));
103
+ [sides[i], sides[j]] = [sides[j], sides[i]];
104
+ }
105
+
106
+ sortedItems.forEach((item, i) => {
107
+ const candidateSide = sides[i];
108
+ pairs.push({
109
+ pairId: `${candidate}--${item}`,
110
+ candidate,
111
+ item,
112
+ candidateSide,
113
+ referenceSide: candidateSide === 'left' ? 'right' : 'left',
114
+ });
115
+ });
116
+ }
117
+ return pairs;
118
+ }
119
+
120
+ /**
121
+ * Per-candidate side balance. Returned by prepare so a degenerate assignment is
122
+ * visible before judging starts rather than discovered afterwards.
123
+ */
124
+ export function sideBalance(pairs) {
125
+ const per = new Map();
126
+ for (const p of pairs) {
127
+ if (!per.has(p.candidate)) per.set(p.candidate, { left: 0, right: 0 });
128
+ per.get(p.candidate)[p.candidateSide] += 1;
129
+ }
130
+ const rows = [...per.entries()].map(([candidate, { left, right }]) => ({
131
+ candidate, left, right, skew: Math.abs(left - right),
132
+ })).sort((a, b) => a.candidate.localeCompare(b.candidate));
133
+ return { perCandidate: rows, worstSkew: rows.reduce((m, r) => Math.max(m, r.skew), 0) };
134
+ }
135
+
136
+ /** What the judge may see. Carries no hint of which side is which. */
137
+ export function manifestFor(pairs) {
138
+ return {
139
+ pairs: pairs.map((p) => ({ pairId: p.pairId })),
140
+ instructions:
141
+ 'For each pair, two candidates are shown as "left" and "right". Decide which ' +
142
+ 'is better against the stated criterion and answer "left" or "right". You are ' +
143
+ 'not told which is which, and the assignment differs per pair. Answer every pair.',
144
+ };
145
+ }
146
+
147
+ /** The sealed key. Write it separately and withhold it until verdicts are in. */
148
+ export function keyFor(pairs, seed) {
149
+ return { seed, pairs };
150
+ }
151
+
152
+ // ---------------------------------------------------------------- intervals
153
+
154
+ /**
155
+ * Wilson score interval for a binomial proportion.
156
+ *
157
+ * WHY NOT THE NORMAL APPROXIMATION. The textbook `p ± z·sqrt(p(1-p)/n)` fails
158
+ * exactly where evaluation panels live — small n and lopsided results:
159
+ *
160
+ * - At 0 wins or n wins it collapses to zero width, reporting a result of
161
+ * "100%, ±0" from four comparisons. That is not a tight estimate; it is a
162
+ * missing one.
163
+ * - It routinely produces bounds below 0 or above 1, which are not
164
+ * probabilities.
165
+ * - Its actual coverage at n < 40 is well below the nominal 95%, so it
166
+ * overstates confidence precisely when a reader most needs the opposite.
167
+ *
168
+ * Wilson is bounded in [0,1] by construction, stays sensible at the extremes,
169
+ * and has far better small-sample coverage. It is asymmetric — the interval
170
+ * around 0.9 is not centred on 0.9 — so `lower` and `upper` are reported
171
+ * rather than a single ± figure, because collapsing an asymmetric interval to
172
+ * a symmetric one throws away the part that matters.
173
+ *
174
+ * @param {number} wins successes
175
+ * @param {number} n trials
176
+ * @param {number} z standard-normal quantile; 1.96 ≈ 95%
177
+ */
178
+ export function wilsonInterval(wins, n, z = 1.96) {
179
+ if (!n) return { lower: 0, upper: 1, width: 1, method: 'wilson', z };
180
+ const p = wins / n;
181
+ const z2 = z * z;
182
+ const denom = 1 + z2 / n;
183
+ const centre = (p + z2 / (2 * n)) / denom;
184
+ const margin = (z / denom) * Math.sqrt((p * (1 - p)) / n + z2 / (4 * n * n));
185
+ const lower = Math.max(0, centre - margin);
186
+ const upper = Math.min(1, centre + margin);
187
+ return {
188
+ lower: +lower.toFixed(4),
189
+ upper: +upper.toFixed(4),
190
+ width: +(upper - lower).toFixed(4),
191
+ method: 'wilson',
192
+ z,
193
+ };
194
+ }
195
+
196
+ /** log(n!) by summation. Exact enough for any panel size, no special functions. */
197
+ function logFactorial(n) {
198
+ let s = 0;
199
+ for (let i = 2; i <= n; i++) s += Math.log(i);
200
+ return s;
201
+ }
202
+
203
+ /**
204
+ * EXACT two-sided binomial p-value against a fair coin.
205
+ *
206
+ * Wilson is the right interval to report, but it is still an approximation, and
207
+ * at very small n it is slightly LIBERAL at the extremes. Concretely: a perfect
208
+ * 4-of-4 record gives a Wilson lower bound of 0.5101, which excludes 0.5 and
209
+ * would be called a win — while the exact two-sided p for 4-of-4 is 0.125,
210
+ * nowhere near significant. Four consecutive wins is what a fair coin does one
211
+ * time in eight.
212
+ *
213
+ * A directional claim therefore requires BOTH: an interval that excludes 0.5 and
214
+ * an exact p below the threshold. Belt and braces, because the cost of the two
215
+ * errors is not symmetric — an over-claimed evaluation result is repeated in a
216
+ * deck long after anyone can check it.
217
+ *
218
+ * Computed in log space so it does not overflow on large panels.
219
+ */
220
+ export function exactBinomialP(wins, n) {
221
+ if (!n) return 1;
222
+ const w = Math.max(wins, n - wins); // symmetric under p = 0.5
223
+ if (w === n - w) return 1;
224
+ const lnFacN = logFactorial(n);
225
+ const lnHalfN = n * Math.log(2);
226
+ let tail = 0;
227
+ for (let k = w; k <= n; k++) {
228
+ tail += Math.exp(lnFacN - logFactorial(k) - logFactorial(n - k) - lnHalfN);
229
+ }
230
+ return Math.min(1, +(2 * tail).toFixed(6));
231
+ }
232
+
233
+ /**
234
+ * Is this candidate established as better than the reference?
235
+ *
236
+ * Requires the Wilson interval to exclude 0.5 AND the exact binomial test to
237
+ * reach `alpha`. A win rate of 0.72 whose interval spans 0.43–0.90 has
238
+ * established nothing however large the point estimate looks, and saying so is
239
+ * the entire purpose of reporting an interval.
240
+ */
241
+ export function beatsChance(interval, wins, n, alpha = 0.05) {
242
+ const p = exactBinomialP(wins, n);
243
+ if (p >= alpha) return 'inconclusive';
244
+ if (interval.lower > 0.5) return 'better';
245
+ if (interval.upper < 0.5) return 'worse';
246
+ return 'inconclusive';
247
+ }
248
+
249
+ // ---------------------------------------------------------------- tally
250
+
251
+ /**
252
+ * Resolve verdicts against the key.
253
+ * @param {Array<{pairId:string, judge:string, choice:'left'|'right'}>} verdicts
254
+ */
255
+ export function tally(key, verdicts) {
256
+ const byId = new Map(key.pairs.map((p) => [p.pairId, p]));
257
+ const perCandidate = new Map();
258
+ const perJudge = new Map();
259
+ const unknown = [];
260
+ let leftPicks = 0, total = 0;
261
+
262
+ for (const v of verdicts) {
263
+ const pair = byId.get(v?.pairId);
264
+ if (!pair || (v.choice !== 'left' && v.choice !== 'right')) { unknown.push(v?.pairId); continue; }
265
+
266
+ const candidateWon = v.choice === pair.candidateSide;
267
+ total += 1;
268
+ if (v.choice === 'left') leftPicks += 1;
269
+
270
+ if (!perCandidate.has(pair.candidate)) perCandidate.set(pair.candidate, { wins: 0, n: 0 });
271
+ const c = perCandidate.get(pair.candidate);
272
+ c.n += 1;
273
+ if (candidateWon) c.wins += 1;
274
+
275
+ if (!perJudge.has(v.judge)) perJudge.set(v.judge, new Map());
276
+ perJudge.get(v.judge).set(v.pairId, candidateWon);
277
+ }
278
+
279
+ const candidates = [...perCandidate.entries()]
280
+ .map(([name, { wins, n }]) => {
281
+ const ci95 = wilsonInterval(wins, n);
282
+ return {
283
+ name, wins, n,
284
+ winRate: n ? +(wins / n).toFixed(4) : 0,
285
+ ci95,
286
+ exactP: exactBinomialP(wins, n),
287
+ // The question actually being asked. A win rate whose interval spans
288
+ // 0.5, or whose exact test does not reach 0.05, has not established
289
+ // that the candidate is better however large the point estimate looks.
290
+ verdict: beatsChance(ci95, wins, n),
291
+ };
292
+ })
293
+ .sort((a, b) => b.winRate - a.winRate || a.name.localeCompare(b.name));
294
+
295
+ return {
296
+ candidates,
297
+ positionBias: {
298
+ leftPicks, total,
299
+ leftRate: total ? +(leftPicks / total).toFixed(4) : 0,
300
+ suspect: total >= 8 && Math.abs(leftPicks / total - 0.5) > 0.25,
301
+ },
302
+ agreement: agreementStats(perJudge),
303
+ unknownPairIds: unknown,
304
+ };
305
+ }
306
+
307
+ /**
308
+ * Pairwise inter-rater agreement on the unblinded outcome.
309
+ *
310
+ * Computed only over pairs BOTH judges answered — scoring a judge against gaps
311
+ * in another's coverage inflates or deflates agreement for no reason.
312
+ */
313
+ export function agreementStats(perJudge) {
314
+ const judges = [...perJudge.keys()].sort();
315
+ const pairwise = [];
316
+
317
+ for (let i = 0; i < judges.length; i++) {
318
+ for (let j = i + 1; j < judges.length; j++) {
319
+ const a = perJudge.get(judges[i]);
320
+ const b = perJudge.get(judges[j]);
321
+ let same = 0, n = 0;
322
+ for (const [pairId, outcome] of a) {
323
+ if (!b.has(pairId)) continue;
324
+ n += 1;
325
+ if (b.get(pairId) === outcome) same += 1;
326
+ }
327
+ pairwise.push({ judges: [judges[i], judges[j]], overlap: n, agreement: n ? +(same / n).toFixed(4) : null });
328
+ }
329
+ }
330
+
331
+ const scored = pairwise.filter((p) => p.agreement != null);
332
+ const mean = scored.length
333
+ ? +(scored.reduce((s, p) => s + p.agreement, 0) / scored.length).toFixed(4)
334
+ : null;
335
+
336
+ return {
337
+ judges,
338
+ pairwise,
339
+ meanPairwiseAgreement: mean,
340
+ /**
341
+ * Below this the panel is not discriminating and the run should be
342
+ * discarded. Note that PERFECT DISAGREEMENT (0) is deliberately not flagged
343
+ * here: it is a signal, usually of an inverted rubric, and conflating it
344
+ * with noise would hide a real and fixable problem.
345
+ */
346
+ nearChance: mean != null && Math.abs(mean - 0.5) < 0.1,
347
+ };
348
+ }
349
+
350
+ /** Problems that invalidate a run. Empty array means the result is usable. */
351
+ export function problemsWith(result) {
352
+ const out = [];
353
+ if (result.unknownPairIds.length) {
354
+ out.push(`${result.unknownPairIds.length} verdict(s) referenced an unknown pairId or an invalid choice`);
355
+ }
356
+ if (result.positionBias.suspect) {
357
+ out.push(`POSITION BIAS: judges chose left ${(result.positionBias.leftRate * 100).toFixed(1)}% of the time — ` +
358
+ 'they may be responding to position rather than content, which invalidates every win rate above');
359
+ }
360
+ if (result.agreement.nearChance) {
361
+ out.push(`AGREEMENT NEAR CHANCE (${result.agreement.meanPairwiseAgreement}): the panel is not detecting a ` +
362
+ 'shared signal, so these win rates are noise however decisive they look');
363
+ }
364
+ return out;
365
+ }