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.
Files changed (48) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +342 -0
  3. package/dist/cli.js +1344 -0
  4. package/dist/index.d.ts +376 -0
  5. package/dist/index.js +971 -0
  6. package/package.json +74 -0
  7. package/spec/README.md +31 -0
  8. package/spec/chunk-id.md +51 -0
  9. package/spec/drift.md +49 -0
  10. package/spec/fixtures/README.md +48 -0
  11. package/spec/fixtures/basic/expected.json +43 -0
  12. package/spec/fixtures/basic/judgments.jsonl +4 -0
  13. package/spec/fixtures/basic/run.jsonl +2 -0
  14. package/spec/fixtures/chunk-id/expected.json +32 -0
  15. package/spec/fixtures/drift/corpus.json +38 -0
  16. package/spec/fixtures/drift/expected.json +22 -0
  17. package/spec/fixtures/drift/judgments.jsonl +4 -0
  18. package/spec/fixtures/duplicate-ranking/expected.json +56 -0
  19. package/spec/fixtures/duplicate-ranking/judgments.jsonl +3 -0
  20. package/spec/fixtures/duplicate-ranking/run-duplicate-query.jsonl +2 -0
  21. package/spec/fixtures/duplicate-ranking/run-non-string-key.jsonl +1 -0
  22. package/spec/fixtures/duplicate-ranking/run.jsonl +2 -0
  23. package/spec/fixtures/merge/corpus.json +22 -0
  24. package/spec/fixtures/merge/expected.json +21 -0
  25. package/spec/fixtures/merge/judgments.jsonl +3 -0
  26. package/spec/fixtures/no-positives/expected.json +17 -0
  27. package/spec/fixtures/no-positives/judgments.jsonl +4 -0
  28. package/spec/fixtures/no-positives/run.jsonl +2 -0
  29. package/spec/fixtures/nothing-scored/expected.json +9 -0
  30. package/spec/fixtures/nothing-scored/judgments.jsonl +1 -0
  31. package/spec/fixtures/nothing-scored/run.jsonl +1 -0
  32. package/spec/fixtures/qrels/beir.tsv +4 -0
  33. package/spec/fixtures/qrels/expected.json +14 -0
  34. package/spec/fixtures/qrels/trec.qrels +4 -0
  35. package/spec/fixtures/strata/expected.json +40 -0
  36. package/spec/fixtures/strata/judgments.jsonl +5 -0
  37. package/spec/fixtures/strata/run.jsonl +5 -0
  38. package/spec/fixtures/stratum-order/expected.json +30 -0
  39. package/spec/fixtures/stratum-order/judgments.jsonl +5 -0
  40. package/spec/fixtures/stratum-order/run.jsonl +5 -0
  41. package/spec/fixtures/summarize/expected.json +53 -0
  42. package/spec/fixtures/unsorted-queries/expected.json +23 -0
  43. package/spec/fixtures/unsorted-queries/judgments.jsonl +3 -0
  44. package/spec/fixtures/unsound-judgments/expected.json +19 -0
  45. package/spec/fixtures/unsound-judgments/judgments.jsonl +3 -0
  46. package/spec/fixtures/unsound-judgments/run.jsonl +1 -0
  47. package/spec/judgments.schema.json +54 -0
  48. 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
+ };