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/cli.js
ADDED
|
@@ -0,0 +1,1344 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { readFileSync, realpathSync, writeFileSync } from "fs";
|
|
5
|
+
import { pathToFileURL } from "url";
|
|
6
|
+
import { parseArgs } from "util";
|
|
7
|
+
|
|
8
|
+
// src/identity.ts
|
|
9
|
+
import { createHash } from "crypto";
|
|
10
|
+
function normalize(text) {
|
|
11
|
+
return text.normalize("NFC").replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim();
|
|
12
|
+
}
|
|
13
|
+
function h128(input) {
|
|
14
|
+
return createHash("sha256").update(input, "utf8").digest("hex").slice(0, 32);
|
|
15
|
+
}
|
|
16
|
+
function textSha(text) {
|
|
17
|
+
return `t1:${h128(normalize(text))}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// src/drift.ts
|
|
21
|
+
function buildIndex(corpus) {
|
|
22
|
+
const byChunkId = /* @__PURE__ */ new Map();
|
|
23
|
+
const byTextSha = /* @__PURE__ */ new Map();
|
|
24
|
+
const byDoc = /* @__PURE__ */ new Map();
|
|
25
|
+
for (const chunk of corpus.chunks) {
|
|
26
|
+
byChunkId.set(chunk.chunk_id, chunk);
|
|
27
|
+
const sha = chunk.text_sha ?? (chunk.text !== void 0 ? textSha(chunk.text) : void 0);
|
|
28
|
+
if (sha) {
|
|
29
|
+
const list = byTextSha.get(sha);
|
|
30
|
+
if (list) list.push(chunk);
|
|
31
|
+
else byTextSha.set(sha, [chunk]);
|
|
32
|
+
}
|
|
33
|
+
const docList = byDoc.get(chunk.doc_uri);
|
|
34
|
+
if (docList) docList.push(chunk);
|
|
35
|
+
else byDoc.set(chunk.doc_uri, [chunk]);
|
|
36
|
+
}
|
|
37
|
+
for (const list of byDoc.values()) {
|
|
38
|
+
list.sort((a, b) => (a.ordinal ?? 0) - (b.ordinal ?? 0));
|
|
39
|
+
}
|
|
40
|
+
return { byChunkId, byTextSha, byDoc };
|
|
41
|
+
}
|
|
42
|
+
function findSplit(labeledText, docChunks) {
|
|
43
|
+
const haystack = normalize(labeledText);
|
|
44
|
+
if (haystack === "") return null;
|
|
45
|
+
const covering = docChunks.filter((chunk) => {
|
|
46
|
+
if (chunk.text === void 0) return false;
|
|
47
|
+
const needle = normalize(chunk.text);
|
|
48
|
+
return needle !== "" && haystack.includes(needle);
|
|
49
|
+
});
|
|
50
|
+
return covering.length >= 2 ? covering : null;
|
|
51
|
+
}
|
|
52
|
+
function findMerged(labeledText, docChunks) {
|
|
53
|
+
const needle = normalize(labeledText);
|
|
54
|
+
if (needle === "") return null;
|
|
55
|
+
const containing = docChunks.filter(
|
|
56
|
+
(chunk) => chunk.text !== void 0 && normalize(chunk.text).includes(needle)
|
|
57
|
+
);
|
|
58
|
+
if (containing.length === 0) return null;
|
|
59
|
+
return containing.reduce(
|
|
60
|
+
(best, chunk) => normalize(chunk.text).length < normalize(best.text).length ? chunk : best
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
function drift(judgments, corpus, options = {}) {
|
|
64
|
+
const index = buildIndex(corpus);
|
|
65
|
+
const findings = [];
|
|
66
|
+
const fingerprints = /* @__PURE__ */ new Set();
|
|
67
|
+
for (const j of judgments) {
|
|
68
|
+
if (j.corpus_fingerprint) fingerprints.add(j.corpus_fingerprint);
|
|
69
|
+
const finding = {
|
|
70
|
+
query_id: j.query_id,
|
|
71
|
+
doc_uri: j.doc_uri,
|
|
72
|
+
status: "ORPHANED"
|
|
73
|
+
};
|
|
74
|
+
if (j.chunk_id !== void 0) finding.chunk_id = j.chunk_id;
|
|
75
|
+
const sha = j.text_sha ?? (j.chunk_text !== void 0 ? textSha(j.chunk_text) : void 0);
|
|
76
|
+
const docChunks = index.byDoc.get(j.doc_uri) ?? [];
|
|
77
|
+
if (j.chunk_id === void 0) {
|
|
78
|
+
finding.status = docChunks.length > 0 ? "VALID" : "ORPHANED";
|
|
79
|
+
findings.push(finding);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (index.byChunkId.has(j.chunk_id)) {
|
|
83
|
+
finding.status = "VALID";
|
|
84
|
+
findings.push(finding);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (sha) {
|
|
88
|
+
const matches = index.byTextSha.get(sha) ?? [];
|
|
89
|
+
const sameDoc = matches.filter((chunk) => chunk.doc_uri === j.doc_uri);
|
|
90
|
+
const target = sameDoc[0] ?? matches[0];
|
|
91
|
+
if (target) {
|
|
92
|
+
finding.status = "RE_ANCHORABLE";
|
|
93
|
+
finding.reanchor_to = target.chunk_id;
|
|
94
|
+
findings.push(finding);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (j.chunk_text !== void 0) {
|
|
99
|
+
const split = findSplit(j.chunk_text, docChunks);
|
|
100
|
+
if (split) {
|
|
101
|
+
finding.status = "SPLIT";
|
|
102
|
+
finding.split_into = split.map((chunk) => chunk.chunk_id);
|
|
103
|
+
findings.push(finding);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const merged = findMerged(j.chunk_text, docChunks);
|
|
107
|
+
if (merged) {
|
|
108
|
+
finding.status = "MERGED";
|
|
109
|
+
finding.reanchor_to = merged.chunk_id;
|
|
110
|
+
findings.push(finding);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
findings.push(finding);
|
|
115
|
+
}
|
|
116
|
+
const count = (status) => findings.filter((f) => f.status === status).length;
|
|
117
|
+
const valid = count("VALID");
|
|
118
|
+
const total = findings.length;
|
|
119
|
+
return {
|
|
120
|
+
findings,
|
|
121
|
+
summary: {
|
|
122
|
+
valid,
|
|
123
|
+
re_anchorable: count("RE_ANCHORABLE"),
|
|
124
|
+
merged: count("MERGED"),
|
|
125
|
+
split: count("SPLIT"),
|
|
126
|
+
orphaned: count("ORPHANED"),
|
|
127
|
+
invalid_ratio: total === 0 ? 0 : Math.round((total - valid) / total * 1e12) / 1e12
|
|
128
|
+
},
|
|
129
|
+
judgments_fingerprint: fingerprints.size === 1 ? [...fingerprints][0] : null,
|
|
130
|
+
corpus_fingerprint: corpus.corpus_fingerprint ?? options.corpusFingerprint ?? null
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
function fix(judgments, result) {
|
|
134
|
+
const byQueryAndChunk = /* @__PURE__ */ new Map();
|
|
135
|
+
for (const finding of result.findings) {
|
|
136
|
+
byQueryAndChunk.set(`${finding.query_id}\0${finding.chunk_id ?? ""}`, finding);
|
|
137
|
+
}
|
|
138
|
+
let reanchored = 0;
|
|
139
|
+
const needsReview = [];
|
|
140
|
+
const out = judgments.map((j) => {
|
|
141
|
+
const finding = byQueryAndChunk.get(`${j.query_id}\0${j.chunk_id ?? ""}`);
|
|
142
|
+
if (!finding) return j;
|
|
143
|
+
if ((finding.status === "RE_ANCHORABLE" || finding.status === "MERGED") && finding.reanchor_to) {
|
|
144
|
+
reanchored++;
|
|
145
|
+
const next = { ...j, chunk_id: finding.reanchor_to };
|
|
146
|
+
if (result.corpus_fingerprint) next.corpus_fingerprint = result.corpus_fingerprint;
|
|
147
|
+
return next;
|
|
148
|
+
}
|
|
149
|
+
if (finding.status === "SPLIT" || finding.status === "ORPHANED") needsReview.push(finding);
|
|
150
|
+
if (finding.status === "VALID" && result.corpus_fingerprint) {
|
|
151
|
+
return { ...j, corpus_fingerprint: result.corpus_fingerprint };
|
|
152
|
+
}
|
|
153
|
+
return j;
|
|
154
|
+
});
|
|
155
|
+
return { judgments: out, reanchored, needsReview };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// src/metrics.ts
|
|
159
|
+
function judgmentKey(j) {
|
|
160
|
+
return j.chunk_id ?? j.doc_uri;
|
|
161
|
+
}
|
|
162
|
+
function dedupe(ranking) {
|
|
163
|
+
const seen = /* @__PURE__ */ new Set();
|
|
164
|
+
const out = [];
|
|
165
|
+
for (const key of ranking) {
|
|
166
|
+
if (seen.has(key)) continue;
|
|
167
|
+
seen.add(key);
|
|
168
|
+
out.push(key);
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
function dcg(gains) {
|
|
173
|
+
let total = 0;
|
|
174
|
+
for (let i = 0; i < gains.length; i++) total += gains[i] / Math.log2(i + 2);
|
|
175
|
+
return total;
|
|
176
|
+
}
|
|
177
|
+
function queryMetrics(relevance, ranking, options = {}) {
|
|
178
|
+
const k = options.k ?? 10;
|
|
179
|
+
const threshold = options.threshold ?? 1;
|
|
180
|
+
const topK = dedupe(ranking).slice(0, k);
|
|
181
|
+
const relevantKeys = new Set(
|
|
182
|
+
[...relevance.entries()].filter(([, r]) => r >= threshold).map(([key]) => key)
|
|
183
|
+
);
|
|
184
|
+
const hits = topK.filter((key) => relevantKeys.has(key));
|
|
185
|
+
const gains = topK.map((key) => 2 ** (relevance.get(key) ?? 0) - 1);
|
|
186
|
+
const idealGains = [...relevance.values()].map((r) => 2 ** r - 1).sort((a, b) => b - a).slice(0, k);
|
|
187
|
+
const idcg = dcg(idealGains);
|
|
188
|
+
let mrr = 0;
|
|
189
|
+
for (let i = 0; i < topK.length; i++) {
|
|
190
|
+
if (relevantKeys.has(topK[i])) {
|
|
191
|
+
mrr = 1 / (i + 1);
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
let found = 0;
|
|
196
|
+
let apSum = 0;
|
|
197
|
+
for (let i = 0; i < topK.length; i++) {
|
|
198
|
+
if (relevantKeys.has(topK[i])) {
|
|
199
|
+
found++;
|
|
200
|
+
apSum += found / (i + 1);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
precision: hits.length / k,
|
|
205
|
+
recall: relevantKeys.size > 0 ? hits.length / relevantKeys.size : 0,
|
|
206
|
+
ndcg: idcg > 0 ? dcg(gains) / idcg : 0,
|
|
207
|
+
mrr,
|
|
208
|
+
ap: relevantKeys.size > 0 ? apSum / relevantKeys.size : 0,
|
|
209
|
+
hit_rate: hits.length > 0 ? 1 : 0
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
function relevanceByQuery(judgments) {
|
|
213
|
+
const out = /* @__PURE__ */ new Map();
|
|
214
|
+
for (const j of judgments) {
|
|
215
|
+
let inner = out.get(j.query_id);
|
|
216
|
+
if (!inner) {
|
|
217
|
+
inner = /* @__PURE__ */ new Map();
|
|
218
|
+
out.set(j.query_id, inner);
|
|
219
|
+
}
|
|
220
|
+
inner.set(judgmentKey(j), j.relevance);
|
|
221
|
+
}
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
var deterministic = (value) => ({
|
|
225
|
+
value: round(value),
|
|
226
|
+
n: 1,
|
|
227
|
+
deterministic: true
|
|
228
|
+
});
|
|
229
|
+
function round(value) {
|
|
230
|
+
return Math.round(value * 1e12) / 1e12;
|
|
231
|
+
}
|
|
232
|
+
function score(judgments, run, options = {}) {
|
|
233
|
+
const k = options.k ?? 10;
|
|
234
|
+
const threshold = options.threshold ?? 1;
|
|
235
|
+
const byQuery = relevanceByQuery(judgments);
|
|
236
|
+
const rankings = new Map(run.map((r) => [r.query_id, r.ranking]));
|
|
237
|
+
const perQuery = /* @__PURE__ */ new Map();
|
|
238
|
+
const missingQueries = [];
|
|
239
|
+
const queriesWithoutPositives = [];
|
|
240
|
+
const queriesWithDuplicates = [];
|
|
241
|
+
const scored = [];
|
|
242
|
+
for (const [queryId, relevance] of byQuery) {
|
|
243
|
+
const ranking = rankings.get(queryId);
|
|
244
|
+
if (!ranking) missingQueries.push(queryId);
|
|
245
|
+
else if (dedupe(ranking).length !== ranking.length) queriesWithDuplicates.push(queryId);
|
|
246
|
+
const metrics = queryMetrics(relevance, ranking ?? [], options);
|
|
247
|
+
perQuery.set(queryId, metrics);
|
|
248
|
+
if ([...relevance.values()].some((r) => r >= threshold)) scored.push(metrics);
|
|
249
|
+
else queriesWithoutPositives.push(queryId);
|
|
250
|
+
}
|
|
251
|
+
if (scored.length === 0) {
|
|
252
|
+
return {
|
|
253
|
+
metrics: {},
|
|
254
|
+
perQuery,
|
|
255
|
+
missingQueries,
|
|
256
|
+
queriesWithoutPositives,
|
|
257
|
+
queriesWithDuplicates
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
const mean = (pick) => scored.reduce((sum, m) => sum + pick(m), 0) / scored.length;
|
|
261
|
+
return {
|
|
262
|
+
metrics: {
|
|
263
|
+
[`precision@${k}`]: deterministic(mean((m) => m.precision)),
|
|
264
|
+
[`recall@${k}`]: deterministic(mean((m) => m.recall)),
|
|
265
|
+
[`ndcg@${k}`]: deterministic(mean((m) => m.ndcg)),
|
|
266
|
+
[`mrr@${k}`]: deterministic(mean((m) => m.mrr)),
|
|
267
|
+
[`map@${k}`]: deterministic(mean((m) => m.ap)),
|
|
268
|
+
[`hit_rate@${k}`]: deterministic(mean((m) => m.hit_rate))
|
|
269
|
+
},
|
|
270
|
+
perQuery,
|
|
271
|
+
missingQueries,
|
|
272
|
+
queriesWithoutPositives,
|
|
273
|
+
queriesWithDuplicates
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
function scoreByStratum(judgments, run, options = {}) {
|
|
277
|
+
const strata = /* @__PURE__ */ new Map();
|
|
278
|
+
for (const j of judgments) {
|
|
279
|
+
const name = j.stratum ?? "_unstratified";
|
|
280
|
+
const list = strata.get(name);
|
|
281
|
+
if (list) list.push(j);
|
|
282
|
+
else strata.set(name, [j]);
|
|
283
|
+
}
|
|
284
|
+
const out = {};
|
|
285
|
+
for (const [name, subset] of strata) {
|
|
286
|
+
const result = score(subset, run, options);
|
|
287
|
+
out[name] = {
|
|
288
|
+
n: result.perQuery.size - result.queriesWithoutPositives.length,
|
|
289
|
+
metrics: result.metrics
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
return out;
|
|
293
|
+
}
|
|
294
|
+
function worstStratum(perStratum, metric) {
|
|
295
|
+
let worst = null;
|
|
296
|
+
for (const [name, stratum] of Object.entries(perStratum)) {
|
|
297
|
+
if (stratum.n === 0) continue;
|
|
298
|
+
const measurement = stratum.metrics[metric];
|
|
299
|
+
if (!measurement) continue;
|
|
300
|
+
if (!worst || measurement.value < worst.value) {
|
|
301
|
+
worst = { name, value: measurement.value, n: stratum.n };
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return worst;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// src/gate.ts
|
|
308
|
+
function parseGate(expression) {
|
|
309
|
+
const parts = expression.split(":");
|
|
310
|
+
if (parts[0] === "worst-stratum") {
|
|
311
|
+
if (parts.length !== 3)
|
|
312
|
+
throw new Error(`gate '${expression}': expected worst-stratum:<metric>:<floor>`);
|
|
313
|
+
return {
|
|
314
|
+
raw: expression,
|
|
315
|
+
kind: "worst-stratum",
|
|
316
|
+
metric: parts[1],
|
|
317
|
+
threshold: parseNumber(parts[2], expression)
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
if (parts.length === 3 && parts[1] === "ci-lower") {
|
|
321
|
+
return {
|
|
322
|
+
raw: expression,
|
|
323
|
+
kind: "ci-lower",
|
|
324
|
+
metric: parts[0],
|
|
325
|
+
threshold: parseNumber(parts[2], expression)
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
if (parts.length !== 2) throw new Error(`gate '${expression}': expected <metric>:<threshold>`);
|
|
329
|
+
const value = parts[1];
|
|
330
|
+
return {
|
|
331
|
+
raw: expression,
|
|
332
|
+
kind: value.startsWith("-") || value.startsWith("+") ? "delta" : "absolute",
|
|
333
|
+
metric: parts[0],
|
|
334
|
+
threshold: parseNumber(value, expression)
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
var NUMBER = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
|
|
338
|
+
function parseNumber(value, expression) {
|
|
339
|
+
if (!NUMBER.test(value)) throw new Error(`gate '${expression}': '${value}' is not a number`);
|
|
340
|
+
return Number.parseFloat(value);
|
|
341
|
+
}
|
|
342
|
+
function worseStatus(a, b) {
|
|
343
|
+
if (a === "FAIL" || b === "FAIL") return "FAIL";
|
|
344
|
+
if (a === "INDETERMINATE" || b === "INDETERMINATE") return "INDETERMINATE";
|
|
345
|
+
return "PASS";
|
|
346
|
+
}
|
|
347
|
+
function evaluateGates({ report: report2, baseline, gates }) {
|
|
348
|
+
const results = [];
|
|
349
|
+
const reasons = [];
|
|
350
|
+
for (const gate of gates) {
|
|
351
|
+
const result = evaluateGate(gate, report2, baseline);
|
|
352
|
+
results.push(result);
|
|
353
|
+
if (result.status === "FAIL" || result.status === "INDETERMINATE") {
|
|
354
|
+
reasons.push(describe(gate, result, report2));
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
const status = results.some((r) => r.status === "FAIL") ? "FAIL" : results.some((r) => r.status === "INDETERMINATE") ? "INDETERMINATE" : "PASS";
|
|
358
|
+
return { status, results, reasons };
|
|
359
|
+
}
|
|
360
|
+
function evaluateGate(gate, report2, baseline) {
|
|
361
|
+
if (gate.kind === "worst-stratum") {
|
|
362
|
+
if (!report2.per_stratum) {
|
|
363
|
+
return { expression: gate.raw, status: "INDETERMINATE" };
|
|
364
|
+
}
|
|
365
|
+
const worst = worstStratum(report2.per_stratum, gate.metric);
|
|
366
|
+
if (!worst) return { expression: gate.raw, status: "INDETERMINATE" };
|
|
367
|
+
return {
|
|
368
|
+
expression: gate.raw,
|
|
369
|
+
status: worst.value >= gate.threshold ? "PASS" : "FAIL",
|
|
370
|
+
observed: worst.value
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
const measurement = report2.metrics[gate.metric];
|
|
374
|
+
if (!measurement) return { expression: gate.raw, status: "INDETERMINATE" };
|
|
375
|
+
if (gate.kind === "ci-lower") {
|
|
376
|
+
if (!measurement.ci) {
|
|
377
|
+
return { expression: gate.raw, status: "INDETERMINATE", observed: measurement.value };
|
|
378
|
+
}
|
|
379
|
+
const lower = measurement.ci[0];
|
|
380
|
+
return {
|
|
381
|
+
expression: gate.raw,
|
|
382
|
+
status: lower >= gate.threshold ? "PASS" : "FAIL",
|
|
383
|
+
observed: lower
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
if (gate.kind === "delta") {
|
|
387
|
+
const previous = baseline?.metrics[gate.metric];
|
|
388
|
+
if (!previous) {
|
|
389
|
+
return {
|
|
390
|
+
expression: gate.raw,
|
|
391
|
+
status: "INDETERMINATE",
|
|
392
|
+
observed: measurement.value,
|
|
393
|
+
baseline: null
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
const delta = measurement.value - previous.value;
|
|
397
|
+
return {
|
|
398
|
+
expression: gate.raw,
|
|
399
|
+
status: delta >= gate.threshold ? "PASS" : "FAIL",
|
|
400
|
+
observed: measurement.value,
|
|
401
|
+
baseline: previous.value
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
return {
|
|
405
|
+
expression: gate.raw,
|
|
406
|
+
status: measurement.value >= gate.threshold ? "PASS" : "FAIL",
|
|
407
|
+
observed: measurement.value
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
function describe(gate, result, report2) {
|
|
411
|
+
if (result.status === "INDETERMINATE") {
|
|
412
|
+
const nothingScored = report2.judgments.queries_scored === 0 && result.observed === void 0;
|
|
413
|
+
if (nothingScored && gate.kind !== "worst-stratum") {
|
|
414
|
+
return `${gate.raw}: no query was scored, so '${gate.metric}' was not computed`;
|
|
415
|
+
}
|
|
416
|
+
if (gate.kind === "ci-lower") {
|
|
417
|
+
return `${gate.raw}: no confidence interval on '${gate.metric}', sample it more than once`;
|
|
418
|
+
}
|
|
419
|
+
if (gate.kind === "delta") return `${gate.raw}: no baseline value for '${gate.metric}'`;
|
|
420
|
+
if (gate.kind === "worst-stratum") {
|
|
421
|
+
const strata = Object.values(report2.per_stratum ?? {});
|
|
422
|
+
if (strata.length > 0 && strata.every((stratum) => stratum.n === 0)) {
|
|
423
|
+
return `${gate.raw}: no stratum was scored for '${gate.metric}'`;
|
|
424
|
+
}
|
|
425
|
+
return `${gate.raw}: no per-stratum data for '${gate.metric}'`;
|
|
426
|
+
}
|
|
427
|
+
return `${gate.raw}: metric '${gate.metric}' not present in the report`;
|
|
428
|
+
}
|
|
429
|
+
if (gate.kind === "delta") {
|
|
430
|
+
const from = result.baseline ?? 0;
|
|
431
|
+
const to = result.observed ?? 0;
|
|
432
|
+
const change = to - from;
|
|
433
|
+
const direction = change < 0 ? `fell ${(-change).toFixed(4)}` : `rose ${change.toFixed(4)}`;
|
|
434
|
+
return `${gate.metric} ${direction}, from ${from.toFixed(4)} to ${to.toFixed(4)}, outside ${gate.threshold}`;
|
|
435
|
+
}
|
|
436
|
+
if (gate.kind === "worst-stratum") {
|
|
437
|
+
return `worst stratum ${gate.metric} is ${(result.observed ?? 0).toFixed(4)}, below ${gate.threshold}`;
|
|
438
|
+
}
|
|
439
|
+
if (gate.kind === "ci-lower") {
|
|
440
|
+
return `${gate.metric} lower bound is ${(result.observed ?? 0).toFixed(4)}, below ${gate.threshold}`;
|
|
441
|
+
}
|
|
442
|
+
return `${gate.metric} is ${(result.observed ?? 0).toFixed(4)}, below ${gate.threshold}`;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// src/judgments.ts
|
|
446
|
+
function parseJsonl(content, label) {
|
|
447
|
+
const out = [];
|
|
448
|
+
const lines = content.split("\n");
|
|
449
|
+
for (let i = 0; i < lines.length; i++) {
|
|
450
|
+
const line = lines[i].trim();
|
|
451
|
+
if (line === "" || line.startsWith("//")) continue;
|
|
452
|
+
try {
|
|
453
|
+
out.push({ value: JSON.parse(line), line: i + 1 });
|
|
454
|
+
} catch (error) {
|
|
455
|
+
throw new Error(`${label}:${i + 1}: invalid JSON, ${error.message}`);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return out;
|
|
459
|
+
}
|
|
460
|
+
function parseJudgments(content, label = "judgments") {
|
|
461
|
+
const rows = parseJsonl(content, label);
|
|
462
|
+
for (const { value, line } of rows) {
|
|
463
|
+
if (typeof value.query_id !== "string" || value.query_id === "")
|
|
464
|
+
throw new Error(`${label}:${line}: missing query_id`);
|
|
465
|
+
if (typeof value.doc_uri !== "string" || value.doc_uri === "")
|
|
466
|
+
throw new Error(`${label}:${line}: missing doc_uri`);
|
|
467
|
+
if (!Number.isInteger(value.relevance) || value.relevance < 0)
|
|
468
|
+
throw new Error(`${label}:${line}: relevance must be a non-negative integer`);
|
|
469
|
+
}
|
|
470
|
+
return rows.map((row) => row.value);
|
|
471
|
+
}
|
|
472
|
+
function parseRun(content, label = "run") {
|
|
473
|
+
const rows = parseJsonl(content, label);
|
|
474
|
+
const firstSeen = /* @__PURE__ */ new Map();
|
|
475
|
+
for (const { value, line } of rows) {
|
|
476
|
+
if (typeof value.query_id !== "string" || value.query_id === "")
|
|
477
|
+
throw new Error(`${label}:${line}: missing query_id`);
|
|
478
|
+
if (!Array.isArray(value.ranking))
|
|
479
|
+
throw new Error(`${label}:${line}: ranking must be an array`);
|
|
480
|
+
for (let i = 0; i < value.ranking.length; i++) {
|
|
481
|
+
const key = value.ranking[i];
|
|
482
|
+
if (typeof key !== "string" || key === "")
|
|
483
|
+
throw new Error(`${label}:${line}: ranking[${i}] must be a non-empty string`);
|
|
484
|
+
}
|
|
485
|
+
const previous = firstSeen.get(value.query_id);
|
|
486
|
+
if (previous !== void 0)
|
|
487
|
+
throw new Error(
|
|
488
|
+
`${label}:${line}: duplicate entry for query ${value.query_id}, already on line ${previous}`
|
|
489
|
+
);
|
|
490
|
+
firstSeen.set(value.query_id, line);
|
|
491
|
+
}
|
|
492
|
+
return rows.map((row) => row.value);
|
|
493
|
+
}
|
|
494
|
+
function byCodePoint(a, b) {
|
|
495
|
+
const left = [...a];
|
|
496
|
+
const right = [...b];
|
|
497
|
+
for (let i = 0; i < Math.min(left.length, right.length); i++) {
|
|
498
|
+
const difference = (left[i].codePointAt(0) ?? 0) - (right[i].codePointAt(0) ?? 0);
|
|
499
|
+
if (difference !== 0) return difference;
|
|
500
|
+
}
|
|
501
|
+
return left.length - right.length;
|
|
502
|
+
}
|
|
503
|
+
var FIELD_ORDER = [
|
|
504
|
+
"query_id",
|
|
505
|
+
"query",
|
|
506
|
+
"doc_uri",
|
|
507
|
+
"chunk_id",
|
|
508
|
+
"text_sha",
|
|
509
|
+
"chunk_text",
|
|
510
|
+
"relevance",
|
|
511
|
+
"corpus_fingerprint",
|
|
512
|
+
"labeled_by",
|
|
513
|
+
"labeled_at",
|
|
514
|
+
"stratum",
|
|
515
|
+
"notes"
|
|
516
|
+
];
|
|
517
|
+
function serializeJudgments(judgments) {
|
|
518
|
+
const lines = judgments.map((judgment) => {
|
|
519
|
+
const ordered = {};
|
|
520
|
+
for (const field of FIELD_ORDER) {
|
|
521
|
+
if (judgment[field] !== void 0) ordered[field] = judgment[field];
|
|
522
|
+
}
|
|
523
|
+
for (const [key, value] of Object.entries(judgment)) {
|
|
524
|
+
if (!FIELD_ORDER.includes(key)) ordered[key] = value;
|
|
525
|
+
}
|
|
526
|
+
return JSON.stringify(ordered);
|
|
527
|
+
});
|
|
528
|
+
return `${lines.join("\n")}
|
|
529
|
+
`;
|
|
530
|
+
}
|
|
531
|
+
function parseCorpus(content, label = "corpus") {
|
|
532
|
+
const parsed = JSON.parse(content);
|
|
533
|
+
if (!Array.isArray(parsed.chunks)) throw new Error(`${label}: missing chunks array`);
|
|
534
|
+
return parsed;
|
|
535
|
+
}
|
|
536
|
+
function validate(judgments) {
|
|
537
|
+
const issues = [];
|
|
538
|
+
const queries = /* @__PURE__ */ new Set();
|
|
539
|
+
const strata = {};
|
|
540
|
+
const fingerprints = /* @__PURE__ */ new Set();
|
|
541
|
+
const seen = /* @__PURE__ */ new Set();
|
|
542
|
+
const queryText = /* @__PURE__ */ new Map();
|
|
543
|
+
let humanLabels = 0;
|
|
544
|
+
let syntheticLabels = 0;
|
|
545
|
+
let positives = 0;
|
|
546
|
+
for (const j of judgments) {
|
|
547
|
+
queries.add(j.query_id);
|
|
548
|
+
if (j.relevance >= 1) positives++;
|
|
549
|
+
if (j.corpus_fingerprint) fingerprints.add(j.corpus_fingerprint);
|
|
550
|
+
if (j.labeled_by?.startsWith("human:")) humanLabels++;
|
|
551
|
+
else if (j.labeled_by?.startsWith("synthetic:")) syntheticLabels++;
|
|
552
|
+
const stratum = j.stratum ?? "_unstratified";
|
|
553
|
+
strata[stratum] = (strata[stratum] ?? 0) + 1;
|
|
554
|
+
const key = `${j.query_id}\0${j.chunk_id ?? j.doc_uri}`;
|
|
555
|
+
if (seen.has(key)) {
|
|
556
|
+
issues.push({
|
|
557
|
+
severity: "error",
|
|
558
|
+
code: "duplicate-label",
|
|
559
|
+
message: `duplicate judgment for query ${j.query_id} and target ${j.chunk_id ?? j.doc_uri}`
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
seen.add(key);
|
|
563
|
+
if (j.query) {
|
|
564
|
+
const existing = queryText.get(j.query_id);
|
|
565
|
+
if (existing !== void 0 && existing !== j.query) {
|
|
566
|
+
issues.push({
|
|
567
|
+
severity: "error",
|
|
568
|
+
code: "inconsistent-query-text",
|
|
569
|
+
message: `query ${j.query_id} has two different query strings`
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
queryText.set(j.query_id, j.query);
|
|
573
|
+
}
|
|
574
|
+
if (j.chunk_id && !/^c1:[0-9a-f]{32}$/.test(j.chunk_id)) {
|
|
575
|
+
issues.push({
|
|
576
|
+
severity: "error",
|
|
577
|
+
code: "bad-chunk-id",
|
|
578
|
+
message: `malformed chunk_id on query ${j.query_id}: ${j.chunk_id}`
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
if (j.text_sha && !/^t1:[0-9a-f]{32}$/.test(j.text_sha)) {
|
|
582
|
+
issues.push({
|
|
583
|
+
severity: "error",
|
|
584
|
+
code: "bad-text-sha",
|
|
585
|
+
message: `malformed text_sha on query ${j.query_id}: ${j.text_sha}`
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
for (const queryId of [...queries].sort(byCodePoint)) {
|
|
590
|
+
if (!queryText.has(queryId)) {
|
|
591
|
+
issues.push({
|
|
592
|
+
severity: "warning",
|
|
593
|
+
code: "missing-query-text",
|
|
594
|
+
message: `query ${queryId} has no query text on any row`
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
if (judgments.length === 0) {
|
|
599
|
+
issues.push({ severity: "error", code: "empty", message: "no judgments" });
|
|
600
|
+
}
|
|
601
|
+
if (positives === 0 && judgments.length > 0) {
|
|
602
|
+
issues.push({
|
|
603
|
+
severity: "error",
|
|
604
|
+
code: "no-positives",
|
|
605
|
+
message: "no judgment has relevance >= 1, so recall is undefined for every query"
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
if (humanLabels === 0 && syntheticLabels > 0) {
|
|
609
|
+
issues.push({
|
|
610
|
+
severity: "warning",
|
|
611
|
+
code: "no-human-labels",
|
|
612
|
+
message: "every label is synthetic. Without human labels you cannot measure judge calibration, and synthetic labels quietly become ground truth"
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
if (fingerprints.size > 1) {
|
|
616
|
+
issues.push({
|
|
617
|
+
severity: "warning",
|
|
618
|
+
code: "mixed-fingerprints",
|
|
619
|
+
message: `judgments span ${fingerprints.size} corpus fingerprints; run 'drift' before trusting any metric`
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
for (const [name, count] of Object.entries(strata).sort(([a], [b]) => byCodePoint(a, b))) {
|
|
623
|
+
if (name !== "_unstratified" && count < 5) {
|
|
624
|
+
issues.push({
|
|
625
|
+
severity: "warning",
|
|
626
|
+
code: "thin-stratum",
|
|
627
|
+
message: `stratum '${name}' has only ${count} labels, too few to gate on`
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
const withChunkId = judgments.filter((j) => j.chunk_id).length;
|
|
632
|
+
if (withChunkId > 0 && withChunkId < judgments.length) {
|
|
633
|
+
issues.push({
|
|
634
|
+
severity: "warning",
|
|
635
|
+
code: "mixed-granularity",
|
|
636
|
+
message: `${withChunkId}/${judgments.length} labels have chunk_id; the rest are document-level`
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
if (withChunkId === 0 && judgments.length > 0) {
|
|
640
|
+
issues.push({
|
|
641
|
+
severity: "warning",
|
|
642
|
+
code: "no-chunk-ids",
|
|
643
|
+
message: "no label has a chunk_id, so drift can only work at document granularity. See spec/chunk-id.md"
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
const fingerprint = fingerprints.size === 1 ? [...fingerprints][0] : null;
|
|
647
|
+
return {
|
|
648
|
+
issues,
|
|
649
|
+
queries: queries.size,
|
|
650
|
+
labels: judgments.length,
|
|
651
|
+
humanLabels,
|
|
652
|
+
syntheticLabels,
|
|
653
|
+
fingerprint,
|
|
654
|
+
strata,
|
|
655
|
+
ok: !issues.some((i) => i.severity === "error")
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// src/report.ts
|
|
660
|
+
var TOOL_NAME = "retrieval-eval";
|
|
661
|
+
var TOOL_VERSION = "0.1.0";
|
|
662
|
+
var SPEC_VERSION = "1";
|
|
663
|
+
function buildReport(options) {
|
|
664
|
+
const { judgments, run, corpus, driftResult } = options;
|
|
665
|
+
const metricOptions = { k: options.k ?? 10, threshold: options.threshold ?? 1 };
|
|
666
|
+
const scored = score(judgments, run, metricOptions);
|
|
667
|
+
const validation = validate(judgments);
|
|
668
|
+
const hasStrata = judgments.some((j) => j.stratum !== void 0);
|
|
669
|
+
const corpusInfo = { fingerprint: corpus?.corpus_fingerprint ?? null };
|
|
670
|
+
if (corpus) {
|
|
671
|
+
corpusInfo.documents = new Set(corpus.chunks.map((c) => c.doc_uri)).size;
|
|
672
|
+
corpusInfo.chunks = corpus.chunks.length;
|
|
673
|
+
}
|
|
674
|
+
const reasons = [];
|
|
675
|
+
if (driftResult && driftResult.summary.invalid_ratio > 0) {
|
|
676
|
+
reasons.push(
|
|
677
|
+
`${Math.round(driftResult.summary.invalid_ratio * 100)}% of judgments no longer match the live corpus`
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
const missingAndScored = scored.missingQueries.filter(
|
|
681
|
+
(id) => !scored.queriesWithoutPositives.includes(id)
|
|
682
|
+
);
|
|
683
|
+
if (missingAndScored.length > 0) {
|
|
684
|
+
reasons.push(`${missingAndScored.length} judged queries had no run entry and scored zero`);
|
|
685
|
+
}
|
|
686
|
+
const queriesScored = validation.queries - scored.queriesWithoutPositives.length;
|
|
687
|
+
if (scored.queriesWithoutPositives.length > 0 && queriesScored > 0) {
|
|
688
|
+
reasons.push(
|
|
689
|
+
`${scored.queriesWithoutPositives.length} judged queries have no label at relevance >= ${metricOptions.threshold} and were excluded from the averages`
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
if (queriesScored === 0) {
|
|
693
|
+
reasons.push(
|
|
694
|
+
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`
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
if (scored.queriesWithDuplicates.length > 0) {
|
|
698
|
+
reasons.push(
|
|
699
|
+
`${scored.queriesWithDuplicates.length} queries repeated a key in their ranking; only the first occurrence of each was counted`
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
const validationErrors = validation.issues.filter((issue) => issue.severity === "error");
|
|
703
|
+
if (validationErrors.length > 0) {
|
|
704
|
+
const codes = [...new Set(validationErrors.map((issue) => issue.code))].sort(byCodePoint);
|
|
705
|
+
reasons.push(
|
|
706
|
+
`the judgment set is unsound (${codes.join(", ")}); run 'retrieval-eval validate' for detail. No metric computed from it can be trusted`
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
const report2 = {
|
|
710
|
+
spec_version: SPEC_VERSION,
|
|
711
|
+
tool: { name: TOOL_NAME, version: TOOL_VERSION },
|
|
712
|
+
generated_at: (options.now ?? /* @__PURE__ */ new Date()).toISOString(),
|
|
713
|
+
corpus: corpusInfo,
|
|
714
|
+
judgments: {
|
|
715
|
+
queries: validation.queries,
|
|
716
|
+
queries_scored: queriesScored,
|
|
717
|
+
labels: validation.labels,
|
|
718
|
+
fingerprint: validation.fingerprint,
|
|
719
|
+
human_labels: validation.humanLabels,
|
|
720
|
+
synthetic_labels: validation.syntheticLabels,
|
|
721
|
+
...driftResult ? { drift: driftResult.summary } : {}
|
|
722
|
+
},
|
|
723
|
+
metrics: scored.metrics,
|
|
724
|
+
...hasStrata ? { per_stratum: scoreByStratum(judgments, run, metricOptions) } : {},
|
|
725
|
+
verdict: {
|
|
726
|
+
status: queriesScored === 0 || validationErrors.length > 0 ? "INDETERMINATE" : "PASS",
|
|
727
|
+
reasons
|
|
728
|
+
}
|
|
729
|
+
};
|
|
730
|
+
return report2;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// src/help.ts
|
|
734
|
+
var DOCS = "https://github.com/aton-of-data/retrieval-eval";
|
|
735
|
+
var COMMANDS = ["drift", "score", "validate", "convert"];
|
|
736
|
+
var ROOT_HELP = `retrieval-eval ${TOOL_VERSION}
|
|
737
|
+
Does your retrieval find the right things, and are your labels still true?
|
|
738
|
+
|
|
739
|
+
Usage
|
|
740
|
+
retrieval-eval <command> [options]
|
|
741
|
+
|
|
742
|
+
Commands
|
|
743
|
+
drift report which relevance labels survived a re-chunk, and re-anchor them
|
|
744
|
+
score compute deterministic retrieval metrics and gate a build on them
|
|
745
|
+
validate check a judgment set for problems before you trust its numbers
|
|
746
|
+
convert move labels and runs between this format and TREC qrels
|
|
747
|
+
|
|
748
|
+
Global options
|
|
749
|
+
-h, --help show this help, or 'retrieval-eval <command> --help'
|
|
750
|
+
-v, --version print the version and exit
|
|
751
|
+
--json emit machine-readable JSON on stdout
|
|
752
|
+
--color <when> auto, always or never (default auto; NO_COLOR is honored)
|
|
753
|
+
|
|
754
|
+
Exit codes
|
|
755
|
+
0 success, every gate passed
|
|
756
|
+
1 a gate failed, a label decayed, or validation found an error
|
|
757
|
+
2 usage error, unreadable file, or malformed input
|
|
758
|
+
|
|
759
|
+
Examples
|
|
760
|
+
retrieval-eval drift --judgments judgments.jsonl --corpus corpus.json --fix
|
|
761
|
+
retrieval-eval score --judgments judgments.jsonl --run hits.jsonl -k 5
|
|
762
|
+
retrieval-eval validate --judgments judgments.jsonl
|
|
763
|
+
retrieval-eval convert --judgments judgments.jsonl --to qrels
|
|
764
|
+
|
|
765
|
+
Docs ${DOCS}`;
|
|
766
|
+
var DRIFT_HELP = `retrieval-eval drift
|
|
767
|
+
Report which relevance labels are still true against the live corpus.
|
|
768
|
+
|
|
769
|
+
Usage
|
|
770
|
+
retrieval-eval drift --judgments <file> --corpus <file> [options]
|
|
771
|
+
|
|
772
|
+
Options
|
|
773
|
+
--judgments <file> the labels to check, JSONL
|
|
774
|
+
--corpus <file> the live corpus, JSON
|
|
775
|
+
--fix re-anchor recoverable labels in place, rewriting --judgments
|
|
776
|
+
--json emit every finding as JSON
|
|
777
|
+
--color <when> auto, always or never
|
|
778
|
+
-h, --help show this help
|
|
779
|
+
|
|
780
|
+
Label classes
|
|
781
|
+
VALID the labeled chunk_id is still in the corpus
|
|
782
|
+
RE_ANCHORABLE the labeled text moved to a new chunk_id, --fix recovers it
|
|
783
|
+
MERGED the labeled text was absorbed into a coarser chunk, --fix recovers it
|
|
784
|
+
SPLIT the labeled text now spans several chunks, a human must re-judge
|
|
785
|
+
ORPHANED the labeled text or its document is gone
|
|
786
|
+
|
|
787
|
+
--fix never touches SPLIT or ORPHANED labels. Guessing at them would fabricate ground truth,
|
|
788
|
+
which is the failure this command exists to expose.
|
|
789
|
+
|
|
790
|
+
Exit codes
|
|
791
|
+
0 every label still points at the text it was written for
|
|
792
|
+
1 at least one label has decayed
|
|
793
|
+
2 usage error, unreadable file, or malformed input
|
|
794
|
+
|
|
795
|
+
Docs ${DOCS}/blob/main/spec/drift.md`;
|
|
796
|
+
var SCORE_HELP = `retrieval-eval score
|
|
797
|
+
Compute deterministic retrieval metrics and gate a build on them.
|
|
798
|
+
|
|
799
|
+
Usage
|
|
800
|
+
retrieval-eval score --judgments <file> --run <file> [options]
|
|
801
|
+
|
|
802
|
+
Options
|
|
803
|
+
--judgments <file> relevance labels, JSONL
|
|
804
|
+
--run <file> ranked results per query, JSONL
|
|
805
|
+
--corpus <file> also report judgment drift alongside the metrics
|
|
806
|
+
-k, --k <n> rank cutoff for the @k metrics (default 10)
|
|
807
|
+
--threshold <n> lowest relevance counted as relevant (default 1)
|
|
808
|
+
--gate <expr> add a gate, repeatable; see below
|
|
809
|
+
--baseline <file> a previous report, required by delta gates
|
|
810
|
+
--out <file> write the JSON report to this file
|
|
811
|
+
--json print the JSON report on stdout
|
|
812
|
+
--color <when> auto, always or never
|
|
813
|
+
-h, --help show this help
|
|
814
|
+
|
|
815
|
+
Metrics
|
|
816
|
+
precision@k recall@k ndcg@k mrr map hit_rate@k
|
|
817
|
+
|
|
818
|
+
Gate expressions, repeatable
|
|
819
|
+
recall@5:0.8 absolute floor
|
|
820
|
+
recall@5:-0.02 regression tolerance against --baseline
|
|
821
|
+
worst-stratum:recall@5:0.7 floor on the weakest query class, so averages cannot hide it
|
|
822
|
+
faithfulness:ci-lower:0.8 floor on the lower confidence bound
|
|
823
|
+
|
|
824
|
+
Exit codes
|
|
825
|
+
0 every gate passed
|
|
826
|
+
1 a gate failed or could not be decided
|
|
827
|
+
2 usage error, unreadable file, or malformed input
|
|
828
|
+
|
|
829
|
+
Docs ${DOCS}`;
|
|
830
|
+
var VALIDATE_HELP = `retrieval-eval validate
|
|
831
|
+
Check a judgment set for problems before you trust its numbers.
|
|
832
|
+
|
|
833
|
+
Usage
|
|
834
|
+
retrieval-eval validate --judgments <file> [options]
|
|
835
|
+
|
|
836
|
+
Options
|
|
837
|
+
--judgments <file> the labels to check, JSONL
|
|
838
|
+
--json emit every issue as JSON
|
|
839
|
+
--color <when> auto, always or never
|
|
840
|
+
-h, --help show this help
|
|
841
|
+
|
|
842
|
+
This reports more than schema conformance. A judgment set can parse cleanly and still be
|
|
843
|
+
unsound: every label synthetic, labels spanning several corpus fingerprints, a stratum too thin
|
|
844
|
+
to gate on, or no positive label at all, which leaves recall undefined.
|
|
845
|
+
|
|
846
|
+
Exit codes
|
|
847
|
+
0 no errors, warnings may still be printed
|
|
848
|
+
1 at least one error
|
|
849
|
+
2 usage error, unreadable file, or malformed input
|
|
850
|
+
|
|
851
|
+
Docs ${DOCS}/blob/main/spec/README.md`;
|
|
852
|
+
var CONVERT_HELP = `retrieval-eval convert
|
|
853
|
+
Move labels and runs between this format and TREC qrels.
|
|
854
|
+
|
|
855
|
+
Usage
|
|
856
|
+
retrieval-eval convert --judgments <file> --to qrels
|
|
857
|
+
retrieval-eval convert --run <file> --to trec-run
|
|
858
|
+
retrieval-eval convert --qrels <file> --to judgments [--as-chunk-ids]
|
|
859
|
+
|
|
860
|
+
Options
|
|
861
|
+
--to <format> qrels, trec-run or judgments
|
|
862
|
+
--judgments <file> source labels, for --to qrels
|
|
863
|
+
--run <file> source run, for --to trec-run
|
|
864
|
+
--qrels <file> source qrels, for --to judgments
|
|
865
|
+
--as-chunk-ids treat the qrels doc_id as a chunk_id rather than a doc_uri
|
|
866
|
+
--color <when> auto, always or never
|
|
867
|
+
-h, --help show this help
|
|
868
|
+
|
|
869
|
+
A judgments file is a strict superset of qrels, so one conversion reaches trec_eval,
|
|
870
|
+
ir_measures, pytrec_eval, BEIR and ir_datasets. Converted output goes to stdout.
|
|
871
|
+
|
|
872
|
+
Exit codes
|
|
873
|
+
0 converted
|
|
874
|
+
2 usage error, unreadable file, or malformed input
|
|
875
|
+
|
|
876
|
+
Docs ${DOCS}`;
|
|
877
|
+
var COMMAND_HELP = {
|
|
878
|
+
drift: DRIFT_HELP,
|
|
879
|
+
score: SCORE_HELP,
|
|
880
|
+
validate: VALIDATE_HELP,
|
|
881
|
+
convert: CONVERT_HELP
|
|
882
|
+
};
|
|
883
|
+
|
|
884
|
+
// src/qrels.ts
|
|
885
|
+
function toQrels(judgments) {
|
|
886
|
+
const lines = judgments.map((j) => `${j.query_id} 0 ${j.chunk_id ?? j.doc_uri} ${j.relevance}`);
|
|
887
|
+
return `${lines.join("\n")}
|
|
888
|
+
`;
|
|
889
|
+
}
|
|
890
|
+
function fromQrels(content, options = {}) {
|
|
891
|
+
const out = [];
|
|
892
|
+
const lines = content.split("\n");
|
|
893
|
+
for (let i = 0; i < lines.length; i++) {
|
|
894
|
+
const line = lines[i].trim();
|
|
895
|
+
if (line === "" || line.startsWith("#")) continue;
|
|
896
|
+
const parts = line.split(/\s+/);
|
|
897
|
+
if (parts.length < 3)
|
|
898
|
+
throw new Error(`qrels:${i + 1}: expected 3 or 4 fields, got ${parts.length}`);
|
|
899
|
+
const [queryId, second, third, fourth] = parts;
|
|
900
|
+
const docId = parts.length >= 4 ? third : second;
|
|
901
|
+
const relevance = parts.length >= 4 ? fourth : third;
|
|
902
|
+
const parsed = Number.parseInt(relevance, 10);
|
|
903
|
+
if (Number.isNaN(parsed)) {
|
|
904
|
+
if (out.length === 0 && /^query[-_ ]?id$/i.test(queryId)) continue;
|
|
905
|
+
throw new Error(`qrels:${i + 1}: relevance '${relevance}' is not an integer`);
|
|
906
|
+
}
|
|
907
|
+
const judgment = {
|
|
908
|
+
query_id: queryId,
|
|
909
|
+
doc_uri: docId,
|
|
910
|
+
// Negative relevance appears in some TREC collections; clamp to the schema's minimum.
|
|
911
|
+
relevance: Math.max(0, parsed)
|
|
912
|
+
};
|
|
913
|
+
if (options.asChunkIds) judgment.chunk_id = docId;
|
|
914
|
+
if (options.corpusFingerprint) judgment.corpus_fingerprint = options.corpusFingerprint;
|
|
915
|
+
if (options.labeledBy) judgment.labeled_by = options.labeledBy;
|
|
916
|
+
out.push(judgment);
|
|
917
|
+
}
|
|
918
|
+
return out;
|
|
919
|
+
}
|
|
920
|
+
function toTrecRun(run, runName = "retrieval-eval") {
|
|
921
|
+
const lines = [];
|
|
922
|
+
for (const entry of run) {
|
|
923
|
+
entry.ranking.forEach((docId, index) => {
|
|
924
|
+
const score2 = (entry.ranking.length - index).toFixed(4);
|
|
925
|
+
lines.push(`${entry.query_id} Q0 ${docId} ${index + 1} ${score2} ${runName}`);
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
return `${lines.join("\n")}
|
|
929
|
+
`;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// src/ui.ts
|
|
933
|
+
var CODES = {
|
|
934
|
+
reset: "\x1B[0m",
|
|
935
|
+
dim: "\x1B[2m",
|
|
936
|
+
bold: "\x1B[1m",
|
|
937
|
+
red: "\x1B[31m",
|
|
938
|
+
green: "\x1B[32m",
|
|
939
|
+
yellow: "\x1B[33m",
|
|
940
|
+
cyan: "\x1B[36m"
|
|
941
|
+
};
|
|
942
|
+
var enabled = false;
|
|
943
|
+
function setColor(when, isTty, env) {
|
|
944
|
+
if (when === "never") enabled = false;
|
|
945
|
+
else if (when === "always") enabled = true;
|
|
946
|
+
else enabled = isTty && env.NO_COLOR === void 0;
|
|
947
|
+
}
|
|
948
|
+
function paint(color, text) {
|
|
949
|
+
return enabled ? `${CODES[color]}${text}${CODES.reset}` : text;
|
|
950
|
+
}
|
|
951
|
+
var num = (value) => value.toFixed(4);
|
|
952
|
+
var pct = (value) => `${(value * 100).toFixed(0)}%`;
|
|
953
|
+
function bar(value, width = 8) {
|
|
954
|
+
const clamped = Math.min(Math.max(value, 0), 1);
|
|
955
|
+
const filled = Math.round(clamped * width);
|
|
956
|
+
return "\u2588".repeat(filled) + "\u2591".repeat(width - filled);
|
|
957
|
+
}
|
|
958
|
+
function heading(text) {
|
|
959
|
+
return `
|
|
960
|
+
${paint("dim", text)}
|
|
961
|
+
`;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// src/cli.ts
|
|
965
|
+
var processIo = {
|
|
966
|
+
out: (text) => process.stdout.write(text),
|
|
967
|
+
err: (text) => process.stderr.write(text),
|
|
968
|
+
isTty: process.stdout.isTTY === true,
|
|
969
|
+
env: process.env
|
|
970
|
+
};
|
|
971
|
+
var EXIT_OK = 0;
|
|
972
|
+
var EXIT_FAILED = 1;
|
|
973
|
+
var EXIT_USAGE = 2;
|
|
974
|
+
var CliError = class extends Error {
|
|
975
|
+
constructor(message, hint) {
|
|
976
|
+
super(message);
|
|
977
|
+
this.hint = hint;
|
|
978
|
+
}
|
|
979
|
+
hint;
|
|
980
|
+
};
|
|
981
|
+
var OPTIONS = {
|
|
982
|
+
judgments: { type: "string" },
|
|
983
|
+
run: { type: "string" },
|
|
984
|
+
corpus: { type: "string" },
|
|
985
|
+
qrels: { type: "string" },
|
|
986
|
+
baseline: { type: "string" },
|
|
987
|
+
out: { type: "string" },
|
|
988
|
+
to: { type: "string" },
|
|
989
|
+
k: { type: "string", short: "k" },
|
|
990
|
+
threshold: { type: "string" },
|
|
991
|
+
color: { type: "string" },
|
|
992
|
+
gate: { type: "string", multiple: true },
|
|
993
|
+
fix: { type: "boolean", default: false },
|
|
994
|
+
"as-chunk-ids": { type: "boolean", default: false },
|
|
995
|
+
json: { type: "boolean", default: false },
|
|
996
|
+
help: { type: "boolean", short: "h", default: false },
|
|
997
|
+
version: { type: "boolean", short: "v", default: false }
|
|
998
|
+
};
|
|
999
|
+
function main(argv, io = processIo) {
|
|
1000
|
+
let parsed;
|
|
1001
|
+
try {
|
|
1002
|
+
parsed = parseArgs({ args: argv, options: OPTIONS, allowPositionals: true });
|
|
1003
|
+
} catch (error) {
|
|
1004
|
+
const unknown = /Unknown option '([^']+)'/.exec(error.message);
|
|
1005
|
+
const message = unknown ? `unknown option '${unknown[1]}'` : error.message;
|
|
1006
|
+
return report(io, new CliError(message, "retrieval-eval --help"));
|
|
1007
|
+
}
|
|
1008
|
+
const { values, positionals } = parsed;
|
|
1009
|
+
setColor("auto", io.isTty, io.env);
|
|
1010
|
+
try {
|
|
1011
|
+
setColor(resolveColor(values.color), io.isTty, io.env);
|
|
1012
|
+
if (values.version) {
|
|
1013
|
+
io.out(`${TOOL_VERSION}
|
|
1014
|
+
`);
|
|
1015
|
+
return EXIT_OK;
|
|
1016
|
+
}
|
|
1017
|
+
const [first, second] = positionals;
|
|
1018
|
+
const command = first === "help" ? second : first;
|
|
1019
|
+
if (command === void 0) {
|
|
1020
|
+
io.out(`${ROOT_HELP}
|
|
1021
|
+
`);
|
|
1022
|
+
return values.help || first === "help" ? EXIT_OK : EXIT_USAGE;
|
|
1023
|
+
}
|
|
1024
|
+
if (!isCommand(command)) {
|
|
1025
|
+
const near = closest(command);
|
|
1026
|
+
throw new CliError(
|
|
1027
|
+
`unknown command '${command}'`,
|
|
1028
|
+
near ? `retrieval-eval ${near}` : "retrieval-eval --help"
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
1031
|
+
if (values.help || first === "help") {
|
|
1032
|
+
io.out(`${COMMAND_HELP[command]}
|
|
1033
|
+
`);
|
|
1034
|
+
return EXIT_OK;
|
|
1035
|
+
}
|
|
1036
|
+
switch (command) {
|
|
1037
|
+
case "drift":
|
|
1038
|
+
return runDrift(values, io);
|
|
1039
|
+
case "score":
|
|
1040
|
+
return runScore(values, io);
|
|
1041
|
+
case "validate":
|
|
1042
|
+
return runValidate(values, io);
|
|
1043
|
+
case "convert":
|
|
1044
|
+
return runConvert(values, io);
|
|
1045
|
+
}
|
|
1046
|
+
} catch (error) {
|
|
1047
|
+
return report(io, error);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
function isCommand(value) {
|
|
1051
|
+
return COMMANDS.includes(value);
|
|
1052
|
+
}
|
|
1053
|
+
function closest(typo) {
|
|
1054
|
+
let best;
|
|
1055
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
1056
|
+
for (const candidate of COMMANDS) {
|
|
1057
|
+
const distance = editDistance(typo, candidate);
|
|
1058
|
+
if (distance < bestDistance) {
|
|
1059
|
+
bestDistance = distance;
|
|
1060
|
+
best = candidate;
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
return bestDistance <= 3 ? best : void 0;
|
|
1064
|
+
}
|
|
1065
|
+
function editDistance(a, b) {
|
|
1066
|
+
let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
1067
|
+
for (let i = 1; i <= a.length; i++) {
|
|
1068
|
+
const current = [i];
|
|
1069
|
+
for (let j = 1; j <= b.length; j++) {
|
|
1070
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
1071
|
+
current[j] = Math.min(
|
|
1072
|
+
current[j - 1] + 1,
|
|
1073
|
+
previous[j] + 1,
|
|
1074
|
+
previous[j - 1] + cost
|
|
1075
|
+
);
|
|
1076
|
+
}
|
|
1077
|
+
previous = current;
|
|
1078
|
+
}
|
|
1079
|
+
return previous[b.length];
|
|
1080
|
+
}
|
|
1081
|
+
function resolveColor(value) {
|
|
1082
|
+
if (value === void 0) return "auto";
|
|
1083
|
+
if (value === "auto" || value === "always" || value === "never") return value;
|
|
1084
|
+
throw new CliError(`--color expects auto, always or never, got '${value}'`);
|
|
1085
|
+
}
|
|
1086
|
+
function report(io, error) {
|
|
1087
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1088
|
+
io.err(`${paint("red", "error")} ${message}
|
|
1089
|
+
`);
|
|
1090
|
+
if (error instanceof CliError && error.hint !== void 0) {
|
|
1091
|
+
io.err(`${paint("dim", " try")} ${error.hint}
|
|
1092
|
+
`);
|
|
1093
|
+
}
|
|
1094
|
+
return EXIT_USAGE;
|
|
1095
|
+
}
|
|
1096
|
+
var READ_ERRORS = {
|
|
1097
|
+
ENOENT: "no such file or directory",
|
|
1098
|
+
EACCES: "permission denied",
|
|
1099
|
+
EISDIR: "is a directory"
|
|
1100
|
+
};
|
|
1101
|
+
function read(path) {
|
|
1102
|
+
try {
|
|
1103
|
+
return readFileSync(path, "utf8");
|
|
1104
|
+
} catch (error) {
|
|
1105
|
+
const code = error.code ?? "";
|
|
1106
|
+
throw new CliError(`cannot read ${path}: ${READ_ERRORS[code] ?? error.message}`);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
function required(values, command, flags) {
|
|
1110
|
+
const missing = flags.filter((flag) => values[flag] === void 0);
|
|
1111
|
+
if (missing.length > 0) {
|
|
1112
|
+
throw new CliError(
|
|
1113
|
+
`${command} needs ${flags.map((flag) => `--${flag}`).join(" and ")}`,
|
|
1114
|
+
`retrieval-eval ${command} --help`
|
|
1115
|
+
);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
function integer(raw, flag, fallback) {
|
|
1119
|
+
if (raw === void 0) return fallback;
|
|
1120
|
+
const value = Number.parseInt(raw, 10);
|
|
1121
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
1122
|
+
throw new CliError(`${flag} expects a positive integer, got '${raw}'`);
|
|
1123
|
+
}
|
|
1124
|
+
return value;
|
|
1125
|
+
}
|
|
1126
|
+
function runDrift(values, io) {
|
|
1127
|
+
required(values, "drift", ["judgments", "corpus"]);
|
|
1128
|
+
const path = values.judgments;
|
|
1129
|
+
const judgments = parseJudgments(read(path));
|
|
1130
|
+
const corpus = parseCorpus(read(values.corpus));
|
|
1131
|
+
const result = drift(judgments, corpus);
|
|
1132
|
+
if (values.json) io.out(`${JSON.stringify(result, null, 2)}
|
|
1133
|
+
`);
|
|
1134
|
+
else printDrift(io, result, judgments.length, values.fix === true);
|
|
1135
|
+
if (values.fix) {
|
|
1136
|
+
const fixed = fix(judgments, result);
|
|
1137
|
+
writeFileSync(path, serializeJudgments(fixed.judgments), "utf8");
|
|
1138
|
+
io.out(
|
|
1139
|
+
`
|
|
1140
|
+
${paint("green", "fixed")} re-anchored ${fixed.reanchored} label(s) in ${path}
|
|
1141
|
+
${paint("yellow", "review")} ${fixed.needsReview.length} label(s) still need a human
|
|
1142
|
+
`
|
|
1143
|
+
);
|
|
1144
|
+
}
|
|
1145
|
+
return result.summary.invalid_ratio > 0 ? EXIT_FAILED : EXIT_OK;
|
|
1146
|
+
}
|
|
1147
|
+
function printDrift(io, result, total, fixing) {
|
|
1148
|
+
const s = result.summary;
|
|
1149
|
+
const row = (mark, color, count, label, note) => ` ${paint(color, mark.padEnd(2))} ${String(count).padStart(3)} ${label.padEnd(14)} ${paint("dim", note)}
|
|
1150
|
+
`;
|
|
1151
|
+
let out = "\n";
|
|
1152
|
+
out += ` ${total} judgments \xB7 labeled @ fingerprint ${paint("cyan", result.judgments_fingerprint ?? "unknown")}
|
|
1153
|
+
`;
|
|
1154
|
+
out += ` live corpus @ fingerprint ${paint("cyan", result.corpus_fingerprint ?? "unknown")}
|
|
1155
|
+
|
|
1156
|
+
`;
|
|
1157
|
+
out += row("ok", "green", s.valid, "VALID", "chunk_id still present");
|
|
1158
|
+
out += row("!", "yellow", s.re_anchorable, "RE-ANCHORABLE", "text moved to a new chunk_id");
|
|
1159
|
+
out += row("!", "yellow", s.merged, "MERGED", "text absorbed into a coarser chunk");
|
|
1160
|
+
out += row("!", "yellow", s.split, "SPLIT", "labeled text now spans 2+ chunks");
|
|
1161
|
+
out += row("x", "red", s.orphaned, "ORPHANED", "source text or document is gone");
|
|
1162
|
+
if (s.invalid_ratio > 0) {
|
|
1163
|
+
const recoverable = s.re_anchorable + s.merged;
|
|
1164
|
+
const needsHuman = s.split + s.orphaned;
|
|
1165
|
+
out += `
|
|
1166
|
+
${paint("bold", `${pct(s.invalid_ratio)} of your judgment set no longer matches the live corpus.`)}
|
|
1167
|
+
`;
|
|
1168
|
+
out += ` ${paint("dim", "Any metric computed against it is measuring two changes at once.")}
|
|
1169
|
+
`;
|
|
1170
|
+
out += ` ${paint("dim", `${recoverable} recoverable automatically \xB7 ${needsHuman} need a human`)}
|
|
1171
|
+
`;
|
|
1172
|
+
if (recoverable > 0 && !fixing) {
|
|
1173
|
+
out += `
|
|
1174
|
+
${paint("dim", "next")} retrieval-eval drift --fix, to re-anchor the recoverable labels
|
|
1175
|
+
`;
|
|
1176
|
+
}
|
|
1177
|
+
} else {
|
|
1178
|
+
out += `
|
|
1179
|
+
${paint("green", "Every label still points at the text it was written for.")}
|
|
1180
|
+
`;
|
|
1181
|
+
}
|
|
1182
|
+
io.out(out);
|
|
1183
|
+
}
|
|
1184
|
+
function runScore(values, io) {
|
|
1185
|
+
required(values, "score", ["judgments", "run"]);
|
|
1186
|
+
const judgments = parseJudgments(read(values.judgments));
|
|
1187
|
+
const run = parseRun(read(values.run));
|
|
1188
|
+
const k = integer(values.k, "-k", 10);
|
|
1189
|
+
const threshold = integer(values.threshold, "--threshold", 1);
|
|
1190
|
+
const corpus = values.corpus ? parseCorpus(read(values.corpus)) : void 0;
|
|
1191
|
+
const driftResult = corpus ? drift(judgments, corpus) : void 0;
|
|
1192
|
+
const report2 = buildReport({ judgments, run, k, threshold, corpus, driftResult });
|
|
1193
|
+
const gates = (values.gate ?? []).map(parseGate);
|
|
1194
|
+
if (gates.length > 0) {
|
|
1195
|
+
const baseline = values.baseline ? JSON.parse(read(values.baseline)) : void 0;
|
|
1196
|
+
const evaluated = evaluateGates({ report: report2, baseline, gates });
|
|
1197
|
+
report2.verdict.status = worseStatus(report2.verdict.status, evaluated.status);
|
|
1198
|
+
report2.verdict.gates = evaluated.results;
|
|
1199
|
+
report2.verdict.reasons = [...report2.verdict.reasons, ...evaluated.reasons];
|
|
1200
|
+
}
|
|
1201
|
+
if (values.out) writeFileSync(values.out, `${JSON.stringify(report2, null, 2)}
|
|
1202
|
+
`, "utf8");
|
|
1203
|
+
if (values.json) io.out(`${JSON.stringify(report2, null, 2)}
|
|
1204
|
+
`);
|
|
1205
|
+
else printScore(io, report2);
|
|
1206
|
+
return report2.verdict.status === "PASS" ? EXIT_OK : EXIT_FAILED;
|
|
1207
|
+
}
|
|
1208
|
+
function printScore(io, report2) {
|
|
1209
|
+
let out = `
|
|
1210
|
+
${report2.judgments.queries} queries \xB7 ${report2.judgments.labels} labels`;
|
|
1211
|
+
if (report2.judgments.human_labels !== void 0) {
|
|
1212
|
+
out += paint(
|
|
1213
|
+
"dim",
|
|
1214
|
+
` (${report2.judgments.human_labels} human, ${report2.judgments.synthetic_labels ?? 0} synthetic)`
|
|
1215
|
+
);
|
|
1216
|
+
}
|
|
1217
|
+
out += "\n\n";
|
|
1218
|
+
if (Object.keys(report2.metrics).length === 0) {
|
|
1219
|
+
out += ` ${paint("dim", "no metric was computed")}
|
|
1220
|
+
`;
|
|
1221
|
+
}
|
|
1222
|
+
for (const [name, measurement] of Object.entries(report2.metrics)) {
|
|
1223
|
+
const ci = measurement.ci ? paint("dim", ` [${num(measurement.ci[0])}, ${num(measurement.ci[1])}]`) : "";
|
|
1224
|
+
out += ` ${name.padEnd(16)} ${num(measurement.value)}${ci}
|
|
1225
|
+
`;
|
|
1226
|
+
}
|
|
1227
|
+
if (report2.per_stratum) {
|
|
1228
|
+
const names = Object.keys(report2.metrics);
|
|
1229
|
+
const primary = names.find((name) => name.startsWith("recall@")) ?? names[0] ?? "";
|
|
1230
|
+
const entries = Object.entries(report2.per_stratum).map(([name, stratum]) => ({
|
|
1231
|
+
name,
|
|
1232
|
+
n: stratum.n,
|
|
1233
|
+
value: stratum.metrics[primary]?.value ?? 0
|
|
1234
|
+
}));
|
|
1235
|
+
const ranked = entries.filter((stratum) => stratum.n > 0).sort((a, b) => a.value - b.value || byCodePoint(a.name, b.name));
|
|
1236
|
+
const unscored = entries.filter((stratum) => stratum.n === 0).sort((a, b) => byCodePoint(a.name, b.name));
|
|
1237
|
+
out += heading(primary ? `${primary} by stratum` : "strata");
|
|
1238
|
+
for (const [index, stratum] of ranked.entries()) {
|
|
1239
|
+
const flag = index === 0 && ranked.length > 1 ? paint("yellow", " worst") : "";
|
|
1240
|
+
out += ` ${paint("dim", "\xB7")} ${stratum.name.padEnd(20)} ${num(stratum.value)} ${paint("dim", bar(stratum.value))} ${paint("dim", `n=${stratum.n}`)}${flag}
|
|
1241
|
+
`;
|
|
1242
|
+
}
|
|
1243
|
+
for (const stratum of unscored) {
|
|
1244
|
+
out += ` ${paint("dim", "\xB7")} ${stratum.name.padEnd(20)} ${paint("dim", "not scored, no label at the relevance threshold")}
|
|
1245
|
+
`;
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
if (report2.judgments.drift && report2.judgments.drift.invalid_ratio > 0) {
|
|
1249
|
+
out += `
|
|
1250
|
+
${paint("yellow", "warning")} ${pct(report2.judgments.drift.invalid_ratio)} of judgments no longer match the corpus
|
|
1251
|
+
`;
|
|
1252
|
+
}
|
|
1253
|
+
const gates = report2.verdict.gates ?? [];
|
|
1254
|
+
if (gates.length > 0) {
|
|
1255
|
+
out += heading("gates");
|
|
1256
|
+
for (const gate of gates) {
|
|
1257
|
+
const mark = gate.status === "PASS" ? paint("green", "ok") : gate.status === "FAIL" ? paint("red", "x ") : paint("yellow", "? ");
|
|
1258
|
+
out += ` ${mark} ${gate.expression}
|
|
1259
|
+
`;
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
for (const reason of report2.verdict.reasons) {
|
|
1263
|
+
out += ` ${paint("dim", `\u2192 ${reason}`)}
|
|
1264
|
+
`;
|
|
1265
|
+
}
|
|
1266
|
+
const color = report2.verdict.status === "PASS" ? "green" : report2.verdict.status === "FAIL" ? "red" : "yellow";
|
|
1267
|
+
out += `
|
|
1268
|
+
${paint(color, paint("bold", report2.verdict.status))}
|
|
1269
|
+
`;
|
|
1270
|
+
io.out(out);
|
|
1271
|
+
}
|
|
1272
|
+
function runValidate(values, io) {
|
|
1273
|
+
required(values, "validate", ["judgments"]);
|
|
1274
|
+
const result = validate(parseJudgments(read(values.judgments)));
|
|
1275
|
+
if (values.json) {
|
|
1276
|
+
io.out(`${JSON.stringify(result, null, 2)}
|
|
1277
|
+
`);
|
|
1278
|
+
return result.ok ? EXIT_OK : EXIT_FAILED;
|
|
1279
|
+
}
|
|
1280
|
+
let out = `
|
|
1281
|
+
${result.labels} labels \xB7 ${result.queries} queries \xB7 ${Object.keys(result.strata).length} strata
|
|
1282
|
+
|
|
1283
|
+
`;
|
|
1284
|
+
if (result.issues.length === 0) {
|
|
1285
|
+
out += ` ${paint("green", "ok")} no issues
|
|
1286
|
+
`;
|
|
1287
|
+
} else {
|
|
1288
|
+
for (const issue of result.issues) {
|
|
1289
|
+
const mark = issue.severity === "error" ? paint("red", "error ") : paint("yellow", "warning");
|
|
1290
|
+
out += ` ${mark} ${paint("dim", `[${issue.code}]`)} ${issue.message}
|
|
1291
|
+
`;
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
io.out(out);
|
|
1295
|
+
return result.ok ? EXIT_OK : EXIT_FAILED;
|
|
1296
|
+
}
|
|
1297
|
+
function runConvert(values, io) {
|
|
1298
|
+
const target = values.to;
|
|
1299
|
+
if (target === void 0) {
|
|
1300
|
+
throw new CliError(
|
|
1301
|
+
"convert needs --to qrels, trec-run or judgments",
|
|
1302
|
+
"retrieval-eval convert --help"
|
|
1303
|
+
);
|
|
1304
|
+
}
|
|
1305
|
+
switch (target) {
|
|
1306
|
+
case "judgments": {
|
|
1307
|
+
required(values, "convert", ["qrels"]);
|
|
1308
|
+
const judgments = fromQrels(read(values.qrels), {
|
|
1309
|
+
asChunkIds: values["as-chunk-ids"] === true
|
|
1310
|
+
});
|
|
1311
|
+
io.out(serializeJudgments(judgments));
|
|
1312
|
+
return EXIT_OK;
|
|
1313
|
+
}
|
|
1314
|
+
case "qrels": {
|
|
1315
|
+
required(values, "convert", ["judgments"]);
|
|
1316
|
+
io.out(toQrels(parseJudgments(read(values.judgments))));
|
|
1317
|
+
return EXIT_OK;
|
|
1318
|
+
}
|
|
1319
|
+
case "trec-run": {
|
|
1320
|
+
required(values, "convert", ["run"]);
|
|
1321
|
+
io.out(toTrecRun(parseRun(read(values.run))));
|
|
1322
|
+
return EXIT_OK;
|
|
1323
|
+
}
|
|
1324
|
+
default:
|
|
1325
|
+
throw new CliError(
|
|
1326
|
+
`unknown --to '${target}', expected qrels, trec-run or judgments`,
|
|
1327
|
+
"retrieval-eval convert --help"
|
|
1328
|
+
);
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
function isEntrypoint(entry) {
|
|
1332
|
+
if (entry === void 0) return false;
|
|
1333
|
+
try {
|
|
1334
|
+
return import.meta.url === pathToFileURL(realpathSync(entry)).href;
|
|
1335
|
+
} catch {
|
|
1336
|
+
return false;
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
if (isEntrypoint(process.argv[1])) {
|
|
1340
|
+
process.exit(main(process.argv.slice(2)));
|
|
1341
|
+
}
|
|
1342
|
+
export {
|
|
1343
|
+
main
|
|
1344
|
+
};
|