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 +21 -0
- package/README.md +137 -0
- package/bin/blind-panel.mjs +116 -0
- package/package.json +17 -0
- package/src/blind.js +365 -0
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
|
+
}
|