retrieval-eval 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 +202 -0
- package/README.md +342 -0
- package/dist/cli.js +1344 -0
- package/dist/index.d.ts +376 -0
- package/dist/index.js +971 -0
- package/package.json +74 -0
- package/spec/README.md +31 -0
- package/spec/chunk-id.md +51 -0
- package/spec/drift.md +49 -0
- package/spec/fixtures/README.md +48 -0
- package/spec/fixtures/basic/expected.json +43 -0
- package/spec/fixtures/basic/judgments.jsonl +4 -0
- package/spec/fixtures/basic/run.jsonl +2 -0
- package/spec/fixtures/chunk-id/expected.json +32 -0
- package/spec/fixtures/drift/corpus.json +38 -0
- package/spec/fixtures/drift/expected.json +22 -0
- package/spec/fixtures/drift/judgments.jsonl +4 -0
- package/spec/fixtures/duplicate-ranking/expected.json +56 -0
- package/spec/fixtures/duplicate-ranking/judgments.jsonl +3 -0
- package/spec/fixtures/duplicate-ranking/run-duplicate-query.jsonl +2 -0
- package/spec/fixtures/duplicate-ranking/run-non-string-key.jsonl +1 -0
- package/spec/fixtures/duplicate-ranking/run.jsonl +2 -0
- package/spec/fixtures/merge/corpus.json +22 -0
- package/spec/fixtures/merge/expected.json +21 -0
- package/spec/fixtures/merge/judgments.jsonl +3 -0
- package/spec/fixtures/no-positives/expected.json +17 -0
- package/spec/fixtures/no-positives/judgments.jsonl +4 -0
- package/spec/fixtures/no-positives/run.jsonl +2 -0
- package/spec/fixtures/nothing-scored/expected.json +9 -0
- package/spec/fixtures/nothing-scored/judgments.jsonl +1 -0
- package/spec/fixtures/nothing-scored/run.jsonl +1 -0
- package/spec/fixtures/qrels/beir.tsv +4 -0
- package/spec/fixtures/qrels/expected.json +14 -0
- package/spec/fixtures/qrels/trec.qrels +4 -0
- package/spec/fixtures/strata/expected.json +40 -0
- package/spec/fixtures/strata/judgments.jsonl +5 -0
- package/spec/fixtures/strata/run.jsonl +5 -0
- package/spec/fixtures/stratum-order/expected.json +30 -0
- package/spec/fixtures/stratum-order/judgments.jsonl +5 -0
- package/spec/fixtures/stratum-order/run.jsonl +5 -0
- package/spec/fixtures/summarize/expected.json +53 -0
- package/spec/fixtures/unsorted-queries/expected.json +23 -0
- package/spec/fixtures/unsorted-queries/judgments.jsonl +3 -0
- package/spec/fixtures/unsound-judgments/expected.json +19 -0
- package/spec/fixtures/unsound-judgments/judgments.jsonl +3 -0
- package/spec/fixtures/unsound-judgments/run.jsonl +1 -0
- package/spec/judgments.schema.json +54 -0
- package/spec/report.schema.json +227 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,971 @@
|
|
|
1
|
+
// src/identity.ts
|
|
2
|
+
import { createHash } from "crypto";
|
|
3
|
+
var SEP = "";
|
|
4
|
+
function normalize(text) {
|
|
5
|
+
return text.normalize("NFC").replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim();
|
|
6
|
+
}
|
|
7
|
+
function h128(input) {
|
|
8
|
+
return createHash("sha256").update(input, "utf8").digest("hex").slice(0, 32);
|
|
9
|
+
}
|
|
10
|
+
function textSha(text) {
|
|
11
|
+
return `t1:${h128(normalize(text))}`;
|
|
12
|
+
}
|
|
13
|
+
function chunkId(input) {
|
|
14
|
+
const canonical = [
|
|
15
|
+
input.docUri,
|
|
16
|
+
input.docRevision,
|
|
17
|
+
String(input.ordinal),
|
|
18
|
+
normalize(input.text),
|
|
19
|
+
input.chunkerFingerprint
|
|
20
|
+
].join(SEP);
|
|
21
|
+
return `c1:${h128(canonical)}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// src/metrics.ts
|
|
25
|
+
function judgmentKey(j) {
|
|
26
|
+
return j.chunk_id ?? j.doc_uri;
|
|
27
|
+
}
|
|
28
|
+
function dedupe(ranking) {
|
|
29
|
+
const seen = /* @__PURE__ */ new Set();
|
|
30
|
+
const out = [];
|
|
31
|
+
for (const key of ranking) {
|
|
32
|
+
if (seen.has(key)) continue;
|
|
33
|
+
seen.add(key);
|
|
34
|
+
out.push(key);
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
function dcg(gains) {
|
|
39
|
+
let total = 0;
|
|
40
|
+
for (let i = 0; i < gains.length; i++) total += gains[i] / Math.log2(i + 2);
|
|
41
|
+
return total;
|
|
42
|
+
}
|
|
43
|
+
function queryMetrics(relevance, ranking, options = {}) {
|
|
44
|
+
const k = options.k ?? 10;
|
|
45
|
+
const threshold = options.threshold ?? 1;
|
|
46
|
+
const topK = dedupe(ranking).slice(0, k);
|
|
47
|
+
const relevantKeys = new Set(
|
|
48
|
+
[...relevance.entries()].filter(([, r]) => r >= threshold).map(([key]) => key)
|
|
49
|
+
);
|
|
50
|
+
const hits = topK.filter((key) => relevantKeys.has(key));
|
|
51
|
+
const gains = topK.map((key) => 2 ** (relevance.get(key) ?? 0) - 1);
|
|
52
|
+
const idealGains = [...relevance.values()].map((r) => 2 ** r - 1).sort((a, b) => b - a).slice(0, k);
|
|
53
|
+
const idcg = dcg(idealGains);
|
|
54
|
+
let mrr = 0;
|
|
55
|
+
for (let i = 0; i < topK.length; i++) {
|
|
56
|
+
if (relevantKeys.has(topK[i])) {
|
|
57
|
+
mrr = 1 / (i + 1);
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
let found = 0;
|
|
62
|
+
let apSum = 0;
|
|
63
|
+
for (let i = 0; i < topK.length; i++) {
|
|
64
|
+
if (relevantKeys.has(topK[i])) {
|
|
65
|
+
found++;
|
|
66
|
+
apSum += found / (i + 1);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
precision: hits.length / k,
|
|
71
|
+
recall: relevantKeys.size > 0 ? hits.length / relevantKeys.size : 0,
|
|
72
|
+
ndcg: idcg > 0 ? dcg(gains) / idcg : 0,
|
|
73
|
+
mrr,
|
|
74
|
+
ap: relevantKeys.size > 0 ? apSum / relevantKeys.size : 0,
|
|
75
|
+
hit_rate: hits.length > 0 ? 1 : 0
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function relevanceByQuery(judgments) {
|
|
79
|
+
const out = /* @__PURE__ */ new Map();
|
|
80
|
+
for (const j of judgments) {
|
|
81
|
+
let inner = out.get(j.query_id);
|
|
82
|
+
if (!inner) {
|
|
83
|
+
inner = /* @__PURE__ */ new Map();
|
|
84
|
+
out.set(j.query_id, inner);
|
|
85
|
+
}
|
|
86
|
+
inner.set(judgmentKey(j), j.relevance);
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
var deterministic = (value) => ({
|
|
91
|
+
value: round(value),
|
|
92
|
+
n: 1,
|
|
93
|
+
deterministic: true
|
|
94
|
+
});
|
|
95
|
+
function round(value) {
|
|
96
|
+
return Math.round(value * 1e12) / 1e12;
|
|
97
|
+
}
|
|
98
|
+
var T_95 = [
|
|
99
|
+
0,
|
|
100
|
+
12.706204736175,
|
|
101
|
+
4.302652729749,
|
|
102
|
+
3.182446305284,
|
|
103
|
+
2.776445105198,
|
|
104
|
+
2.570581835636,
|
|
105
|
+
2.446911851145,
|
|
106
|
+
2.364624251593,
|
|
107
|
+
2.306004135204,
|
|
108
|
+
2.262157162798,
|
|
109
|
+
2.228138851986,
|
|
110
|
+
2.200985160092,
|
|
111
|
+
2.178812829667,
|
|
112
|
+
2.160368656463,
|
|
113
|
+
2.144786687918,
|
|
114
|
+
2.13144954556,
|
|
115
|
+
2.119905299221,
|
|
116
|
+
2.109815577833,
|
|
117
|
+
2.100922040241,
|
|
118
|
+
2.093024054408,
|
|
119
|
+
2.085963447266,
|
|
120
|
+
2.079613844728,
|
|
121
|
+
2.073873067904,
|
|
122
|
+
2.068657610419,
|
|
123
|
+
2.063898561628,
|
|
124
|
+
2.059538552753,
|
|
125
|
+
2.055529438643,
|
|
126
|
+
2.05183051648,
|
|
127
|
+
2.048407141795,
|
|
128
|
+
2.045229642133,
|
|
129
|
+
2.042272456301,
|
|
130
|
+
2.039513446396,
|
|
131
|
+
2.03693334346,
|
|
132
|
+
2.034515297449,
|
|
133
|
+
2.032244509318,
|
|
134
|
+
2.03010792825,
|
|
135
|
+
2.02809400098,
|
|
136
|
+
2.026192463029,
|
|
137
|
+
2.024394163912,
|
|
138
|
+
2.022690920037,
|
|
139
|
+
2.021075390306,
|
|
140
|
+
2.019540970441,
|
|
141
|
+
2.018081702818,
|
|
142
|
+
2.016692199228,
|
|
143
|
+
2.015367574444,
|
|
144
|
+
2.014103388881,
|
|
145
|
+
2.012895598919,
|
|
146
|
+
2.01174051373,
|
|
147
|
+
2.010634757624,
|
|
148
|
+
2.009575237129,
|
|
149
|
+
2.008559112101,
|
|
150
|
+
2.007583770316,
|
|
151
|
+
2.006646805062,
|
|
152
|
+
2.005745995318,
|
|
153
|
+
2.004879288188,
|
|
154
|
+
2.004044783289,
|
|
155
|
+
2.003240718848,
|
|
156
|
+
2.002465459291,
|
|
157
|
+
2.001717484145,
|
|
158
|
+
2.000995378088,
|
|
159
|
+
2.000297822014,
|
|
160
|
+
1.999623584995,
|
|
161
|
+
1.998971517033,
|
|
162
|
+
1.998340542521,
|
|
163
|
+
1.997729654318,
|
|
164
|
+
1.997137908392,
|
|
165
|
+
1.996564418952,
|
|
166
|
+
1.996008354025,
|
|
167
|
+
1.99546893143,
|
|
168
|
+
1.994945415107,
|
|
169
|
+
1.994437111771,
|
|
170
|
+
1.993943367846,
|
|
171
|
+
1.993463566662,
|
|
172
|
+
1.99299712589,
|
|
173
|
+
1.992543495181,
|
|
174
|
+
1.992102154002,
|
|
175
|
+
1.991672609645,
|
|
176
|
+
1.991254395388,
|
|
177
|
+
1.990847068812,
|
|
178
|
+
1.99045021023,
|
|
179
|
+
1.990063421254,
|
|
180
|
+
1.989686323457,
|
|
181
|
+
1.989318557137,
|
|
182
|
+
1.988959780175,
|
|
183
|
+
1.988609666976,
|
|
184
|
+
1.988267907477,
|
|
185
|
+
1.987934206239,
|
|
186
|
+
1.987608281589,
|
|
187
|
+
1.987289864831,
|
|
188
|
+
1.986978699506,
|
|
189
|
+
1.986674540704,
|
|
190
|
+
1.986377154419,
|
|
191
|
+
1.986086316951,
|
|
192
|
+
1.985801814346,
|
|
193
|
+
1.985523441867,
|
|
194
|
+
1.985251003505,
|
|
195
|
+
1.984984311522,
|
|
196
|
+
1.984723186014,
|
|
197
|
+
1.984467454508,
|
|
198
|
+
1.984216951586,
|
|
199
|
+
1.983971518524,
|
|
200
|
+
1.983731002956,
|
|
201
|
+
1.983495258563,
|
|
202
|
+
1.983264144773,
|
|
203
|
+
1.983037526484,
|
|
204
|
+
1.982815273795,
|
|
205
|
+
1.982597261765,
|
|
206
|
+
1.982383370176,
|
|
207
|
+
1.982173483308,
|
|
208
|
+
1.981967489736,
|
|
209
|
+
1.981765282132,
|
|
210
|
+
1.981566757075,
|
|
211
|
+
1.981371814876,
|
|
212
|
+
1.981180359415,
|
|
213
|
+
1.980992297976,
|
|
214
|
+
1.980807541104,
|
|
215
|
+
1.980626002459,
|
|
216
|
+
1.980447598683,
|
|
217
|
+
1.980272249273,
|
|
218
|
+
1.980099876457,
|
|
219
|
+
1.979930405082
|
|
220
|
+
];
|
|
221
|
+
function summarize(samples) {
|
|
222
|
+
if (samples.length === 0) throw new Error("summarize needs at least one sample");
|
|
223
|
+
const n = samples.length;
|
|
224
|
+
const mean = samples.reduce((sum, value) => sum + value, 0) / n;
|
|
225
|
+
if (n < 2) return { value: round(mean), n, deterministic: false };
|
|
226
|
+
const variance = samples.reduce((sum, value) => sum + (value - mean) ** 2, 0) / (n - 1);
|
|
227
|
+
const stdev = Math.sqrt(variance);
|
|
228
|
+
const df = Math.min(n - 1, T_95.length - 1);
|
|
229
|
+
const margin = T_95[df] * stdev / Math.sqrt(n);
|
|
230
|
+
return {
|
|
231
|
+
value: round(mean),
|
|
232
|
+
n,
|
|
233
|
+
stdev: round(stdev),
|
|
234
|
+
ci: [round(mean - margin), round(mean + margin)],
|
|
235
|
+
deterministic: false
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
function score(judgments, run, options = {}) {
|
|
239
|
+
const k = options.k ?? 10;
|
|
240
|
+
const threshold = options.threshold ?? 1;
|
|
241
|
+
const byQuery = relevanceByQuery(judgments);
|
|
242
|
+
const rankings = new Map(run.map((r) => [r.query_id, r.ranking]));
|
|
243
|
+
const perQuery = /* @__PURE__ */ new Map();
|
|
244
|
+
const missingQueries = [];
|
|
245
|
+
const queriesWithoutPositives = [];
|
|
246
|
+
const queriesWithDuplicates = [];
|
|
247
|
+
const scored = [];
|
|
248
|
+
for (const [queryId, relevance] of byQuery) {
|
|
249
|
+
const ranking = rankings.get(queryId);
|
|
250
|
+
if (!ranking) missingQueries.push(queryId);
|
|
251
|
+
else if (dedupe(ranking).length !== ranking.length) queriesWithDuplicates.push(queryId);
|
|
252
|
+
const metrics = queryMetrics(relevance, ranking ?? [], options);
|
|
253
|
+
perQuery.set(queryId, metrics);
|
|
254
|
+
if ([...relevance.values()].some((r) => r >= threshold)) scored.push(metrics);
|
|
255
|
+
else queriesWithoutPositives.push(queryId);
|
|
256
|
+
}
|
|
257
|
+
if (scored.length === 0) {
|
|
258
|
+
return {
|
|
259
|
+
metrics: {},
|
|
260
|
+
perQuery,
|
|
261
|
+
missingQueries,
|
|
262
|
+
queriesWithoutPositives,
|
|
263
|
+
queriesWithDuplicates
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
const mean = (pick) => scored.reduce((sum, m) => sum + pick(m), 0) / scored.length;
|
|
267
|
+
return {
|
|
268
|
+
metrics: {
|
|
269
|
+
[`precision@${k}`]: deterministic(mean((m) => m.precision)),
|
|
270
|
+
[`recall@${k}`]: deterministic(mean((m) => m.recall)),
|
|
271
|
+
[`ndcg@${k}`]: deterministic(mean((m) => m.ndcg)),
|
|
272
|
+
[`mrr@${k}`]: deterministic(mean((m) => m.mrr)),
|
|
273
|
+
[`map@${k}`]: deterministic(mean((m) => m.ap)),
|
|
274
|
+
[`hit_rate@${k}`]: deterministic(mean((m) => m.hit_rate))
|
|
275
|
+
},
|
|
276
|
+
perQuery,
|
|
277
|
+
missingQueries,
|
|
278
|
+
queriesWithoutPositives,
|
|
279
|
+
queriesWithDuplicates
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
function scoreByStratum(judgments, run, options = {}) {
|
|
283
|
+
const strata = /* @__PURE__ */ new Map();
|
|
284
|
+
for (const j of judgments) {
|
|
285
|
+
const name = j.stratum ?? "_unstratified";
|
|
286
|
+
const list = strata.get(name);
|
|
287
|
+
if (list) list.push(j);
|
|
288
|
+
else strata.set(name, [j]);
|
|
289
|
+
}
|
|
290
|
+
const out = {};
|
|
291
|
+
for (const [name, subset] of strata) {
|
|
292
|
+
const result = score(subset, run, options);
|
|
293
|
+
out[name] = {
|
|
294
|
+
n: result.perQuery.size - result.queriesWithoutPositives.length,
|
|
295
|
+
metrics: result.metrics
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
return out;
|
|
299
|
+
}
|
|
300
|
+
function worstStratum(perStratum, metric) {
|
|
301
|
+
let worst = null;
|
|
302
|
+
for (const [name, stratum] of Object.entries(perStratum)) {
|
|
303
|
+
if (stratum.n === 0) continue;
|
|
304
|
+
const measurement = stratum.metrics[metric];
|
|
305
|
+
if (!measurement) continue;
|
|
306
|
+
if (!worst || measurement.value < worst.value) {
|
|
307
|
+
worst = { name, value: measurement.value, n: stratum.n };
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return worst;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// src/judgments.ts
|
|
314
|
+
function parseJsonl(content, label) {
|
|
315
|
+
const out = [];
|
|
316
|
+
const lines = content.split("\n");
|
|
317
|
+
for (let i = 0; i < lines.length; i++) {
|
|
318
|
+
const line = lines[i].trim();
|
|
319
|
+
if (line === "" || line.startsWith("//")) continue;
|
|
320
|
+
try {
|
|
321
|
+
out.push({ value: JSON.parse(line), line: i + 1 });
|
|
322
|
+
} catch (error) {
|
|
323
|
+
throw new Error(`${label}:${i + 1}: invalid JSON, ${error.message}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return out;
|
|
327
|
+
}
|
|
328
|
+
function parseJudgments(content, label = "judgments") {
|
|
329
|
+
const rows = parseJsonl(content, label);
|
|
330
|
+
for (const { value, line } of rows) {
|
|
331
|
+
if (typeof value.query_id !== "string" || value.query_id === "")
|
|
332
|
+
throw new Error(`${label}:${line}: missing query_id`);
|
|
333
|
+
if (typeof value.doc_uri !== "string" || value.doc_uri === "")
|
|
334
|
+
throw new Error(`${label}:${line}: missing doc_uri`);
|
|
335
|
+
if (!Number.isInteger(value.relevance) || value.relevance < 0)
|
|
336
|
+
throw new Error(`${label}:${line}: relevance must be a non-negative integer`);
|
|
337
|
+
}
|
|
338
|
+
return rows.map((row) => row.value);
|
|
339
|
+
}
|
|
340
|
+
function parseRun(content, label = "run") {
|
|
341
|
+
const rows = parseJsonl(content, label);
|
|
342
|
+
const firstSeen = /* @__PURE__ */ new Map();
|
|
343
|
+
for (const { value, line } of rows) {
|
|
344
|
+
if (typeof value.query_id !== "string" || value.query_id === "")
|
|
345
|
+
throw new Error(`${label}:${line}: missing query_id`);
|
|
346
|
+
if (!Array.isArray(value.ranking))
|
|
347
|
+
throw new Error(`${label}:${line}: ranking must be an array`);
|
|
348
|
+
for (let i = 0; i < value.ranking.length; i++) {
|
|
349
|
+
const key = value.ranking[i];
|
|
350
|
+
if (typeof key !== "string" || key === "")
|
|
351
|
+
throw new Error(`${label}:${line}: ranking[${i}] must be a non-empty string`);
|
|
352
|
+
}
|
|
353
|
+
const previous = firstSeen.get(value.query_id);
|
|
354
|
+
if (previous !== void 0)
|
|
355
|
+
throw new Error(
|
|
356
|
+
`${label}:${line}: duplicate entry for query ${value.query_id}, already on line ${previous}`
|
|
357
|
+
);
|
|
358
|
+
firstSeen.set(value.query_id, line);
|
|
359
|
+
}
|
|
360
|
+
return rows.map((row) => row.value);
|
|
361
|
+
}
|
|
362
|
+
function byCodePoint(a, b) {
|
|
363
|
+
const left = [...a];
|
|
364
|
+
const right = [...b];
|
|
365
|
+
for (let i = 0; i < Math.min(left.length, right.length); i++) {
|
|
366
|
+
const difference = (left[i].codePointAt(0) ?? 0) - (right[i].codePointAt(0) ?? 0);
|
|
367
|
+
if (difference !== 0) return difference;
|
|
368
|
+
}
|
|
369
|
+
return left.length - right.length;
|
|
370
|
+
}
|
|
371
|
+
var FIELD_ORDER = [
|
|
372
|
+
"query_id",
|
|
373
|
+
"query",
|
|
374
|
+
"doc_uri",
|
|
375
|
+
"chunk_id",
|
|
376
|
+
"text_sha",
|
|
377
|
+
"chunk_text",
|
|
378
|
+
"relevance",
|
|
379
|
+
"corpus_fingerprint",
|
|
380
|
+
"labeled_by",
|
|
381
|
+
"labeled_at",
|
|
382
|
+
"stratum",
|
|
383
|
+
"notes"
|
|
384
|
+
];
|
|
385
|
+
function serializeJudgments(judgments) {
|
|
386
|
+
const lines = judgments.map((judgment) => {
|
|
387
|
+
const ordered = {};
|
|
388
|
+
for (const field of FIELD_ORDER) {
|
|
389
|
+
if (judgment[field] !== void 0) ordered[field] = judgment[field];
|
|
390
|
+
}
|
|
391
|
+
for (const [key, value] of Object.entries(judgment)) {
|
|
392
|
+
if (!FIELD_ORDER.includes(key)) ordered[key] = value;
|
|
393
|
+
}
|
|
394
|
+
return JSON.stringify(ordered);
|
|
395
|
+
});
|
|
396
|
+
return `${lines.join("\n")}
|
|
397
|
+
`;
|
|
398
|
+
}
|
|
399
|
+
function parseCorpus(content, label = "corpus") {
|
|
400
|
+
const parsed = JSON.parse(content);
|
|
401
|
+
if (!Array.isArray(parsed.chunks)) throw new Error(`${label}: missing chunks array`);
|
|
402
|
+
return parsed;
|
|
403
|
+
}
|
|
404
|
+
function validate(judgments) {
|
|
405
|
+
const issues = [];
|
|
406
|
+
const queries = /* @__PURE__ */ new Set();
|
|
407
|
+
const strata = {};
|
|
408
|
+
const fingerprints = /* @__PURE__ */ new Set();
|
|
409
|
+
const seen = /* @__PURE__ */ new Set();
|
|
410
|
+
const queryText = /* @__PURE__ */ new Map();
|
|
411
|
+
let humanLabels = 0;
|
|
412
|
+
let syntheticLabels = 0;
|
|
413
|
+
let positives = 0;
|
|
414
|
+
for (const j of judgments) {
|
|
415
|
+
queries.add(j.query_id);
|
|
416
|
+
if (j.relevance >= 1) positives++;
|
|
417
|
+
if (j.corpus_fingerprint) fingerprints.add(j.corpus_fingerprint);
|
|
418
|
+
if (j.labeled_by?.startsWith("human:")) humanLabels++;
|
|
419
|
+
else if (j.labeled_by?.startsWith("synthetic:")) syntheticLabels++;
|
|
420
|
+
const stratum = j.stratum ?? "_unstratified";
|
|
421
|
+
strata[stratum] = (strata[stratum] ?? 0) + 1;
|
|
422
|
+
const key = `${j.query_id}\0${j.chunk_id ?? j.doc_uri}`;
|
|
423
|
+
if (seen.has(key)) {
|
|
424
|
+
issues.push({
|
|
425
|
+
severity: "error",
|
|
426
|
+
code: "duplicate-label",
|
|
427
|
+
message: `duplicate judgment for query ${j.query_id} and target ${j.chunk_id ?? j.doc_uri}`
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
seen.add(key);
|
|
431
|
+
if (j.query) {
|
|
432
|
+
const existing = queryText.get(j.query_id);
|
|
433
|
+
if (existing !== void 0 && existing !== j.query) {
|
|
434
|
+
issues.push({
|
|
435
|
+
severity: "error",
|
|
436
|
+
code: "inconsistent-query-text",
|
|
437
|
+
message: `query ${j.query_id} has two different query strings`
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
queryText.set(j.query_id, j.query);
|
|
441
|
+
}
|
|
442
|
+
if (j.chunk_id && !/^c1:[0-9a-f]{32}$/.test(j.chunk_id)) {
|
|
443
|
+
issues.push({
|
|
444
|
+
severity: "error",
|
|
445
|
+
code: "bad-chunk-id",
|
|
446
|
+
message: `malformed chunk_id on query ${j.query_id}: ${j.chunk_id}`
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
if (j.text_sha && !/^t1:[0-9a-f]{32}$/.test(j.text_sha)) {
|
|
450
|
+
issues.push({
|
|
451
|
+
severity: "error",
|
|
452
|
+
code: "bad-text-sha",
|
|
453
|
+
message: `malformed text_sha on query ${j.query_id}: ${j.text_sha}`
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
for (const queryId of [...queries].sort(byCodePoint)) {
|
|
458
|
+
if (!queryText.has(queryId)) {
|
|
459
|
+
issues.push({
|
|
460
|
+
severity: "warning",
|
|
461
|
+
code: "missing-query-text",
|
|
462
|
+
message: `query ${queryId} has no query text on any row`
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if (judgments.length === 0) {
|
|
467
|
+
issues.push({ severity: "error", code: "empty", message: "no judgments" });
|
|
468
|
+
}
|
|
469
|
+
if (positives === 0 && judgments.length > 0) {
|
|
470
|
+
issues.push({
|
|
471
|
+
severity: "error",
|
|
472
|
+
code: "no-positives",
|
|
473
|
+
message: "no judgment has relevance >= 1, so recall is undefined for every query"
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
if (humanLabels === 0 && syntheticLabels > 0) {
|
|
477
|
+
issues.push({
|
|
478
|
+
severity: "warning",
|
|
479
|
+
code: "no-human-labels",
|
|
480
|
+
message: "every label is synthetic. Without human labels you cannot measure judge calibration, and synthetic labels quietly become ground truth"
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
if (fingerprints.size > 1) {
|
|
484
|
+
issues.push({
|
|
485
|
+
severity: "warning",
|
|
486
|
+
code: "mixed-fingerprints",
|
|
487
|
+
message: `judgments span ${fingerprints.size} corpus fingerprints; run 'drift' before trusting any metric`
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
for (const [name, count] of Object.entries(strata).sort(([a], [b]) => byCodePoint(a, b))) {
|
|
491
|
+
if (name !== "_unstratified" && count < 5) {
|
|
492
|
+
issues.push({
|
|
493
|
+
severity: "warning",
|
|
494
|
+
code: "thin-stratum",
|
|
495
|
+
message: `stratum '${name}' has only ${count} labels, too few to gate on`
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
const withChunkId = judgments.filter((j) => j.chunk_id).length;
|
|
500
|
+
if (withChunkId > 0 && withChunkId < judgments.length) {
|
|
501
|
+
issues.push({
|
|
502
|
+
severity: "warning",
|
|
503
|
+
code: "mixed-granularity",
|
|
504
|
+
message: `${withChunkId}/${judgments.length} labels have chunk_id; the rest are document-level`
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
if (withChunkId === 0 && judgments.length > 0) {
|
|
508
|
+
issues.push({
|
|
509
|
+
severity: "warning",
|
|
510
|
+
code: "no-chunk-ids",
|
|
511
|
+
message: "no label has a chunk_id, so drift can only work at document granularity. See spec/chunk-id.md"
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
const fingerprint = fingerprints.size === 1 ? [...fingerprints][0] : null;
|
|
515
|
+
return {
|
|
516
|
+
issues,
|
|
517
|
+
queries: queries.size,
|
|
518
|
+
labels: judgments.length,
|
|
519
|
+
humanLabels,
|
|
520
|
+
syntheticLabels,
|
|
521
|
+
fingerprint,
|
|
522
|
+
strata,
|
|
523
|
+
ok: !issues.some((i) => i.severity === "error")
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// src/qrels.ts
|
|
528
|
+
function toQrels(judgments) {
|
|
529
|
+
const lines = judgments.map((j) => `${j.query_id} 0 ${j.chunk_id ?? j.doc_uri} ${j.relevance}`);
|
|
530
|
+
return `${lines.join("\n")}
|
|
531
|
+
`;
|
|
532
|
+
}
|
|
533
|
+
function fromQrels(content, options = {}) {
|
|
534
|
+
const out = [];
|
|
535
|
+
const lines = content.split("\n");
|
|
536
|
+
for (let i = 0; i < lines.length; i++) {
|
|
537
|
+
const line = lines[i].trim();
|
|
538
|
+
if (line === "" || line.startsWith("#")) continue;
|
|
539
|
+
const parts = line.split(/\s+/);
|
|
540
|
+
if (parts.length < 3)
|
|
541
|
+
throw new Error(`qrels:${i + 1}: expected 3 or 4 fields, got ${parts.length}`);
|
|
542
|
+
const [queryId, second, third, fourth] = parts;
|
|
543
|
+
const docId = parts.length >= 4 ? third : second;
|
|
544
|
+
const relevance = parts.length >= 4 ? fourth : third;
|
|
545
|
+
const parsed = Number.parseInt(relevance, 10);
|
|
546
|
+
if (Number.isNaN(parsed)) {
|
|
547
|
+
if (out.length === 0 && /^query[-_ ]?id$/i.test(queryId)) continue;
|
|
548
|
+
throw new Error(`qrels:${i + 1}: relevance '${relevance}' is not an integer`);
|
|
549
|
+
}
|
|
550
|
+
const judgment = {
|
|
551
|
+
query_id: queryId,
|
|
552
|
+
doc_uri: docId,
|
|
553
|
+
// Negative relevance appears in some TREC collections; clamp to the schema's minimum.
|
|
554
|
+
relevance: Math.max(0, parsed)
|
|
555
|
+
};
|
|
556
|
+
if (options.asChunkIds) judgment.chunk_id = docId;
|
|
557
|
+
if (options.corpusFingerprint) judgment.corpus_fingerprint = options.corpusFingerprint;
|
|
558
|
+
if (options.labeledBy) judgment.labeled_by = options.labeledBy;
|
|
559
|
+
out.push(judgment);
|
|
560
|
+
}
|
|
561
|
+
return out;
|
|
562
|
+
}
|
|
563
|
+
function toTrecRun(run, runName = "retrieval-eval") {
|
|
564
|
+
const lines = [];
|
|
565
|
+
for (const entry of run) {
|
|
566
|
+
entry.ranking.forEach((docId, index) => {
|
|
567
|
+
const score2 = (entry.ranking.length - index).toFixed(4);
|
|
568
|
+
lines.push(`${entry.query_id} Q0 ${docId} ${index + 1} ${score2} ${runName}`);
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
return `${lines.join("\n")}
|
|
572
|
+
`;
|
|
573
|
+
}
|
|
574
|
+
function fromTrecRun(content) {
|
|
575
|
+
const byQuery = /* @__PURE__ */ new Map();
|
|
576
|
+
for (const raw of content.split("\n")) {
|
|
577
|
+
const line = raw.trim();
|
|
578
|
+
if (line === "" || line.startsWith("#")) continue;
|
|
579
|
+
const parts = line.split(/\s+/);
|
|
580
|
+
if (parts.length < 4) continue;
|
|
581
|
+
const [queryId, , docId, rank] = parts;
|
|
582
|
+
const list = byQuery.get(queryId) ?? [];
|
|
583
|
+
list.push({ docId, rank: Number.parseInt(rank, 10) });
|
|
584
|
+
byQuery.set(queryId, list);
|
|
585
|
+
}
|
|
586
|
+
return [...byQuery.entries()].map(([queryId, rows]) => ({
|
|
587
|
+
query_id: queryId,
|
|
588
|
+
ranking: rows.sort((a, b) => a.rank - b.rank).map((r) => r.docId)
|
|
589
|
+
}));
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// src/drift.ts
|
|
593
|
+
function buildIndex(corpus) {
|
|
594
|
+
const byChunkId = /* @__PURE__ */ new Map();
|
|
595
|
+
const byTextSha = /* @__PURE__ */ new Map();
|
|
596
|
+
const byDoc = /* @__PURE__ */ new Map();
|
|
597
|
+
for (const chunk of corpus.chunks) {
|
|
598
|
+
byChunkId.set(chunk.chunk_id, chunk);
|
|
599
|
+
const sha = chunk.text_sha ?? (chunk.text !== void 0 ? textSha(chunk.text) : void 0);
|
|
600
|
+
if (sha) {
|
|
601
|
+
const list = byTextSha.get(sha);
|
|
602
|
+
if (list) list.push(chunk);
|
|
603
|
+
else byTextSha.set(sha, [chunk]);
|
|
604
|
+
}
|
|
605
|
+
const docList = byDoc.get(chunk.doc_uri);
|
|
606
|
+
if (docList) docList.push(chunk);
|
|
607
|
+
else byDoc.set(chunk.doc_uri, [chunk]);
|
|
608
|
+
}
|
|
609
|
+
for (const list of byDoc.values()) {
|
|
610
|
+
list.sort((a, b) => (a.ordinal ?? 0) - (b.ordinal ?? 0));
|
|
611
|
+
}
|
|
612
|
+
return { byChunkId, byTextSha, byDoc };
|
|
613
|
+
}
|
|
614
|
+
function findSplit(labeledText, docChunks) {
|
|
615
|
+
const haystack = normalize(labeledText);
|
|
616
|
+
if (haystack === "") return null;
|
|
617
|
+
const covering = docChunks.filter((chunk) => {
|
|
618
|
+
if (chunk.text === void 0) return false;
|
|
619
|
+
const needle = normalize(chunk.text);
|
|
620
|
+
return needle !== "" && haystack.includes(needle);
|
|
621
|
+
});
|
|
622
|
+
return covering.length >= 2 ? covering : null;
|
|
623
|
+
}
|
|
624
|
+
function findMerged(labeledText, docChunks) {
|
|
625
|
+
const needle = normalize(labeledText);
|
|
626
|
+
if (needle === "") return null;
|
|
627
|
+
const containing = docChunks.filter(
|
|
628
|
+
(chunk) => chunk.text !== void 0 && normalize(chunk.text).includes(needle)
|
|
629
|
+
);
|
|
630
|
+
if (containing.length === 0) return null;
|
|
631
|
+
return containing.reduce(
|
|
632
|
+
(best, chunk) => normalize(chunk.text).length < normalize(best.text).length ? chunk : best
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
function drift(judgments, corpus, options = {}) {
|
|
636
|
+
const index = buildIndex(corpus);
|
|
637
|
+
const findings = [];
|
|
638
|
+
const fingerprints = /* @__PURE__ */ new Set();
|
|
639
|
+
for (const j of judgments) {
|
|
640
|
+
if (j.corpus_fingerprint) fingerprints.add(j.corpus_fingerprint);
|
|
641
|
+
const finding = {
|
|
642
|
+
query_id: j.query_id,
|
|
643
|
+
doc_uri: j.doc_uri,
|
|
644
|
+
status: "ORPHANED"
|
|
645
|
+
};
|
|
646
|
+
if (j.chunk_id !== void 0) finding.chunk_id = j.chunk_id;
|
|
647
|
+
const sha = j.text_sha ?? (j.chunk_text !== void 0 ? textSha(j.chunk_text) : void 0);
|
|
648
|
+
const docChunks = index.byDoc.get(j.doc_uri) ?? [];
|
|
649
|
+
if (j.chunk_id === void 0) {
|
|
650
|
+
finding.status = docChunks.length > 0 ? "VALID" : "ORPHANED";
|
|
651
|
+
findings.push(finding);
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
if (index.byChunkId.has(j.chunk_id)) {
|
|
655
|
+
finding.status = "VALID";
|
|
656
|
+
findings.push(finding);
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
if (sha) {
|
|
660
|
+
const matches = index.byTextSha.get(sha) ?? [];
|
|
661
|
+
const sameDoc = matches.filter((chunk) => chunk.doc_uri === j.doc_uri);
|
|
662
|
+
const target = sameDoc[0] ?? matches[0];
|
|
663
|
+
if (target) {
|
|
664
|
+
finding.status = "RE_ANCHORABLE";
|
|
665
|
+
finding.reanchor_to = target.chunk_id;
|
|
666
|
+
findings.push(finding);
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
if (j.chunk_text !== void 0) {
|
|
671
|
+
const split = findSplit(j.chunk_text, docChunks);
|
|
672
|
+
if (split) {
|
|
673
|
+
finding.status = "SPLIT";
|
|
674
|
+
finding.split_into = split.map((chunk) => chunk.chunk_id);
|
|
675
|
+
findings.push(finding);
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
const merged = findMerged(j.chunk_text, docChunks);
|
|
679
|
+
if (merged) {
|
|
680
|
+
finding.status = "MERGED";
|
|
681
|
+
finding.reanchor_to = merged.chunk_id;
|
|
682
|
+
findings.push(finding);
|
|
683
|
+
continue;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
findings.push(finding);
|
|
687
|
+
}
|
|
688
|
+
const count = (status) => findings.filter((f) => f.status === status).length;
|
|
689
|
+
const valid = count("VALID");
|
|
690
|
+
const total = findings.length;
|
|
691
|
+
return {
|
|
692
|
+
findings,
|
|
693
|
+
summary: {
|
|
694
|
+
valid,
|
|
695
|
+
re_anchorable: count("RE_ANCHORABLE"),
|
|
696
|
+
merged: count("MERGED"),
|
|
697
|
+
split: count("SPLIT"),
|
|
698
|
+
orphaned: count("ORPHANED"),
|
|
699
|
+
invalid_ratio: total === 0 ? 0 : Math.round((total - valid) / total * 1e12) / 1e12
|
|
700
|
+
},
|
|
701
|
+
judgments_fingerprint: fingerprints.size === 1 ? [...fingerprints][0] : null,
|
|
702
|
+
corpus_fingerprint: corpus.corpus_fingerprint ?? options.corpusFingerprint ?? null
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
function fix(judgments, result) {
|
|
706
|
+
const byQueryAndChunk = /* @__PURE__ */ new Map();
|
|
707
|
+
for (const finding of result.findings) {
|
|
708
|
+
byQueryAndChunk.set(`${finding.query_id}\0${finding.chunk_id ?? ""}`, finding);
|
|
709
|
+
}
|
|
710
|
+
let reanchored = 0;
|
|
711
|
+
const needsReview = [];
|
|
712
|
+
const out = judgments.map((j) => {
|
|
713
|
+
const finding = byQueryAndChunk.get(`${j.query_id}\0${j.chunk_id ?? ""}`);
|
|
714
|
+
if (!finding) return j;
|
|
715
|
+
if ((finding.status === "RE_ANCHORABLE" || finding.status === "MERGED") && finding.reanchor_to) {
|
|
716
|
+
reanchored++;
|
|
717
|
+
const next = { ...j, chunk_id: finding.reanchor_to };
|
|
718
|
+
if (result.corpus_fingerprint) next.corpus_fingerprint = result.corpus_fingerprint;
|
|
719
|
+
return next;
|
|
720
|
+
}
|
|
721
|
+
if (finding.status === "SPLIT" || finding.status === "ORPHANED") needsReview.push(finding);
|
|
722
|
+
if (finding.status === "VALID" && result.corpus_fingerprint) {
|
|
723
|
+
return { ...j, corpus_fingerprint: result.corpus_fingerprint };
|
|
724
|
+
}
|
|
725
|
+
return j;
|
|
726
|
+
});
|
|
727
|
+
return { judgments: out, reanchored, needsReview };
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// src/gate.ts
|
|
731
|
+
function parseGate(expression) {
|
|
732
|
+
const parts = expression.split(":");
|
|
733
|
+
if (parts[0] === "worst-stratum") {
|
|
734
|
+
if (parts.length !== 3)
|
|
735
|
+
throw new Error(`gate '${expression}': expected worst-stratum:<metric>:<floor>`);
|
|
736
|
+
return {
|
|
737
|
+
raw: expression,
|
|
738
|
+
kind: "worst-stratum",
|
|
739
|
+
metric: parts[1],
|
|
740
|
+
threshold: parseNumber(parts[2], expression)
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
if (parts.length === 3 && parts[1] === "ci-lower") {
|
|
744
|
+
return {
|
|
745
|
+
raw: expression,
|
|
746
|
+
kind: "ci-lower",
|
|
747
|
+
metric: parts[0],
|
|
748
|
+
threshold: parseNumber(parts[2], expression)
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
if (parts.length !== 2) throw new Error(`gate '${expression}': expected <metric>:<threshold>`);
|
|
752
|
+
const value = parts[1];
|
|
753
|
+
return {
|
|
754
|
+
raw: expression,
|
|
755
|
+
kind: value.startsWith("-") || value.startsWith("+") ? "delta" : "absolute",
|
|
756
|
+
metric: parts[0],
|
|
757
|
+
threshold: parseNumber(value, expression)
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
var NUMBER = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
|
|
761
|
+
function parseNumber(value, expression) {
|
|
762
|
+
if (!NUMBER.test(value)) throw new Error(`gate '${expression}': '${value}' is not a number`);
|
|
763
|
+
return Number.parseFloat(value);
|
|
764
|
+
}
|
|
765
|
+
function worseStatus(a, b) {
|
|
766
|
+
if (a === "FAIL" || b === "FAIL") return "FAIL";
|
|
767
|
+
if (a === "INDETERMINATE" || b === "INDETERMINATE") return "INDETERMINATE";
|
|
768
|
+
return "PASS";
|
|
769
|
+
}
|
|
770
|
+
function evaluateGates({ report, baseline, gates }) {
|
|
771
|
+
const results = [];
|
|
772
|
+
const reasons = [];
|
|
773
|
+
for (const gate of gates) {
|
|
774
|
+
const result = evaluateGate(gate, report, baseline);
|
|
775
|
+
results.push(result);
|
|
776
|
+
if (result.status === "FAIL" || result.status === "INDETERMINATE") {
|
|
777
|
+
reasons.push(describe(gate, result, report));
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
const status = results.some((r) => r.status === "FAIL") ? "FAIL" : results.some((r) => r.status === "INDETERMINATE") ? "INDETERMINATE" : "PASS";
|
|
781
|
+
return { status, results, reasons };
|
|
782
|
+
}
|
|
783
|
+
function evaluateGate(gate, report, baseline) {
|
|
784
|
+
if (gate.kind === "worst-stratum") {
|
|
785
|
+
if (!report.per_stratum) {
|
|
786
|
+
return { expression: gate.raw, status: "INDETERMINATE" };
|
|
787
|
+
}
|
|
788
|
+
const worst = worstStratum(report.per_stratum, gate.metric);
|
|
789
|
+
if (!worst) return { expression: gate.raw, status: "INDETERMINATE" };
|
|
790
|
+
return {
|
|
791
|
+
expression: gate.raw,
|
|
792
|
+
status: worst.value >= gate.threshold ? "PASS" : "FAIL",
|
|
793
|
+
observed: worst.value
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
const measurement = report.metrics[gate.metric];
|
|
797
|
+
if (!measurement) return { expression: gate.raw, status: "INDETERMINATE" };
|
|
798
|
+
if (gate.kind === "ci-lower") {
|
|
799
|
+
if (!measurement.ci) {
|
|
800
|
+
return { expression: gate.raw, status: "INDETERMINATE", observed: measurement.value };
|
|
801
|
+
}
|
|
802
|
+
const lower = measurement.ci[0];
|
|
803
|
+
return {
|
|
804
|
+
expression: gate.raw,
|
|
805
|
+
status: lower >= gate.threshold ? "PASS" : "FAIL",
|
|
806
|
+
observed: lower
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
if (gate.kind === "delta") {
|
|
810
|
+
const previous = baseline?.metrics[gate.metric];
|
|
811
|
+
if (!previous) {
|
|
812
|
+
return {
|
|
813
|
+
expression: gate.raw,
|
|
814
|
+
status: "INDETERMINATE",
|
|
815
|
+
observed: measurement.value,
|
|
816
|
+
baseline: null
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
const delta = measurement.value - previous.value;
|
|
820
|
+
return {
|
|
821
|
+
expression: gate.raw,
|
|
822
|
+
status: delta >= gate.threshold ? "PASS" : "FAIL",
|
|
823
|
+
observed: measurement.value,
|
|
824
|
+
baseline: previous.value
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
return {
|
|
828
|
+
expression: gate.raw,
|
|
829
|
+
status: measurement.value >= gate.threshold ? "PASS" : "FAIL",
|
|
830
|
+
observed: measurement.value
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
function describe(gate, result, report) {
|
|
834
|
+
if (result.status === "INDETERMINATE") {
|
|
835
|
+
const nothingScored = report.judgments.queries_scored === 0 && result.observed === void 0;
|
|
836
|
+
if (nothingScored && gate.kind !== "worst-stratum") {
|
|
837
|
+
return `${gate.raw}: no query was scored, so '${gate.metric}' was not computed`;
|
|
838
|
+
}
|
|
839
|
+
if (gate.kind === "ci-lower") {
|
|
840
|
+
return `${gate.raw}: no confidence interval on '${gate.metric}', sample it more than once`;
|
|
841
|
+
}
|
|
842
|
+
if (gate.kind === "delta") return `${gate.raw}: no baseline value for '${gate.metric}'`;
|
|
843
|
+
if (gate.kind === "worst-stratum") {
|
|
844
|
+
const strata = Object.values(report.per_stratum ?? {});
|
|
845
|
+
if (strata.length > 0 && strata.every((stratum) => stratum.n === 0)) {
|
|
846
|
+
return `${gate.raw}: no stratum was scored for '${gate.metric}'`;
|
|
847
|
+
}
|
|
848
|
+
return `${gate.raw}: no per-stratum data for '${gate.metric}'`;
|
|
849
|
+
}
|
|
850
|
+
return `${gate.raw}: metric '${gate.metric}' not present in the report`;
|
|
851
|
+
}
|
|
852
|
+
if (gate.kind === "delta") {
|
|
853
|
+
const from = result.baseline ?? 0;
|
|
854
|
+
const to = result.observed ?? 0;
|
|
855
|
+
const change = to - from;
|
|
856
|
+
const direction = change < 0 ? `fell ${(-change).toFixed(4)}` : `rose ${change.toFixed(4)}`;
|
|
857
|
+
return `${gate.metric} ${direction}, from ${from.toFixed(4)} to ${to.toFixed(4)}, outside ${gate.threshold}`;
|
|
858
|
+
}
|
|
859
|
+
if (gate.kind === "worst-stratum") {
|
|
860
|
+
return `worst stratum ${gate.metric} is ${(result.observed ?? 0).toFixed(4)}, below ${gate.threshold}`;
|
|
861
|
+
}
|
|
862
|
+
if (gate.kind === "ci-lower") {
|
|
863
|
+
return `${gate.metric} lower bound is ${(result.observed ?? 0).toFixed(4)}, below ${gate.threshold}`;
|
|
864
|
+
}
|
|
865
|
+
return `${gate.metric} is ${(result.observed ?? 0).toFixed(4)}, below ${gate.threshold}`;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// src/report.ts
|
|
869
|
+
var TOOL_NAME = "retrieval-eval";
|
|
870
|
+
var TOOL_VERSION = "0.1.0";
|
|
871
|
+
var SPEC_VERSION = "1";
|
|
872
|
+
function buildReport(options) {
|
|
873
|
+
const { judgments, run, corpus, driftResult } = options;
|
|
874
|
+
const metricOptions = { k: options.k ?? 10, threshold: options.threshold ?? 1 };
|
|
875
|
+
const scored = score(judgments, run, metricOptions);
|
|
876
|
+
const validation = validate(judgments);
|
|
877
|
+
const hasStrata = judgments.some((j) => j.stratum !== void 0);
|
|
878
|
+
const corpusInfo = { fingerprint: corpus?.corpus_fingerprint ?? null };
|
|
879
|
+
if (corpus) {
|
|
880
|
+
corpusInfo.documents = new Set(corpus.chunks.map((c) => c.doc_uri)).size;
|
|
881
|
+
corpusInfo.chunks = corpus.chunks.length;
|
|
882
|
+
}
|
|
883
|
+
const reasons = [];
|
|
884
|
+
if (driftResult && driftResult.summary.invalid_ratio > 0) {
|
|
885
|
+
reasons.push(
|
|
886
|
+
`${Math.round(driftResult.summary.invalid_ratio * 100)}% of judgments no longer match the live corpus`
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
const missingAndScored = scored.missingQueries.filter(
|
|
890
|
+
(id) => !scored.queriesWithoutPositives.includes(id)
|
|
891
|
+
);
|
|
892
|
+
if (missingAndScored.length > 0) {
|
|
893
|
+
reasons.push(`${missingAndScored.length} judged queries had no run entry and scored zero`);
|
|
894
|
+
}
|
|
895
|
+
const queriesScored = validation.queries - scored.queriesWithoutPositives.length;
|
|
896
|
+
if (scored.queriesWithoutPositives.length > 0 && queriesScored > 0) {
|
|
897
|
+
reasons.push(
|
|
898
|
+
`${scored.queriesWithoutPositives.length} judged queries have no label at relevance >= ${metricOptions.threshold} and were excluded from the averages`
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
if (queriesScored === 0) {
|
|
902
|
+
reasons.push(
|
|
903
|
+
validation.queries === 0 ? "no judged queries, so no metric was computed" : `no query had a label at relevance >= ${metricOptions.threshold}, so no metric was computed`
|
|
904
|
+
);
|
|
905
|
+
}
|
|
906
|
+
if (scored.queriesWithDuplicates.length > 0) {
|
|
907
|
+
reasons.push(
|
|
908
|
+
`${scored.queriesWithDuplicates.length} queries repeated a key in their ranking; only the first occurrence of each was counted`
|
|
909
|
+
);
|
|
910
|
+
}
|
|
911
|
+
const validationErrors = validation.issues.filter((issue) => issue.severity === "error");
|
|
912
|
+
if (validationErrors.length > 0) {
|
|
913
|
+
const codes = [...new Set(validationErrors.map((issue) => issue.code))].sort(byCodePoint);
|
|
914
|
+
reasons.push(
|
|
915
|
+
`the judgment set is unsound (${codes.join(", ")}); run 'retrieval-eval validate' for detail. No metric computed from it can be trusted`
|
|
916
|
+
);
|
|
917
|
+
}
|
|
918
|
+
const report = {
|
|
919
|
+
spec_version: SPEC_VERSION,
|
|
920
|
+
tool: { name: TOOL_NAME, version: TOOL_VERSION },
|
|
921
|
+
generated_at: (options.now ?? /* @__PURE__ */ new Date()).toISOString(),
|
|
922
|
+
corpus: corpusInfo,
|
|
923
|
+
judgments: {
|
|
924
|
+
queries: validation.queries,
|
|
925
|
+
queries_scored: queriesScored,
|
|
926
|
+
labels: validation.labels,
|
|
927
|
+
fingerprint: validation.fingerprint,
|
|
928
|
+
human_labels: validation.humanLabels,
|
|
929
|
+
synthetic_labels: validation.syntheticLabels,
|
|
930
|
+
...driftResult ? { drift: driftResult.summary } : {}
|
|
931
|
+
},
|
|
932
|
+
metrics: scored.metrics,
|
|
933
|
+
...hasStrata ? { per_stratum: scoreByStratum(judgments, run, metricOptions) } : {},
|
|
934
|
+
verdict: {
|
|
935
|
+
status: queriesScored === 0 || validationErrors.length > 0 ? "INDETERMINATE" : "PASS",
|
|
936
|
+
reasons
|
|
937
|
+
}
|
|
938
|
+
};
|
|
939
|
+
return report;
|
|
940
|
+
}
|
|
941
|
+
export {
|
|
942
|
+
SPEC_VERSION,
|
|
943
|
+
TOOL_NAME,
|
|
944
|
+
TOOL_VERSION,
|
|
945
|
+
buildReport,
|
|
946
|
+
chunkId,
|
|
947
|
+
dedupe,
|
|
948
|
+
drift,
|
|
949
|
+
evaluateGates,
|
|
950
|
+
fix,
|
|
951
|
+
fromQrels,
|
|
952
|
+
fromTrecRun,
|
|
953
|
+
judgmentKey,
|
|
954
|
+
normalize,
|
|
955
|
+
parseCorpus,
|
|
956
|
+
parseGate,
|
|
957
|
+
parseJudgments,
|
|
958
|
+
parseRun,
|
|
959
|
+
queryMetrics,
|
|
960
|
+
relevanceByQuery,
|
|
961
|
+
score,
|
|
962
|
+
scoreByStratum,
|
|
963
|
+
serializeJudgments,
|
|
964
|
+
summarize,
|
|
965
|
+
textSha,
|
|
966
|
+
toQrels,
|
|
967
|
+
toTrecRun,
|
|
968
|
+
validate,
|
|
969
|
+
worseStatus,
|
|
970
|
+
worstStratum
|
|
971
|
+
};
|