retrieval-eval 0.1.0 → 0.1.1

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/README.md CHANGED
@@ -291,7 +291,7 @@ anywhere, which is the point of a spec.
291
291
 
292
292
  ## Status
293
293
 
294
- **0.1.0, alpha.** The formats and the metric mathematics are what we intend to keep. The CLI
294
+ **0.1.1, alpha.** The formats and the metric mathematics are what we intend to keep. The CLI
295
295
  surface may still move. The `c1:` and `t1:` hash prefixes exist so identity can be versioned
296
296
  without breaking existing judgment files.
297
297
 
package/dist/cli.js CHANGED
@@ -658,7 +658,7 @@ function validate(judgments) {
658
658
 
659
659
  // src/report.ts
660
660
  var TOOL_NAME = "retrieval-eval";
661
- var TOOL_VERSION = "0.1.0";
661
+ var TOOL_VERSION = "0.1.1";
662
662
  var SPEC_VERSION = "1";
663
663
  function buildReport(options) {
664
664
  const { judgments, run, corpus, driftResult } = options;
package/dist/index.d.ts CHANGED
@@ -1,20 +1,3 @@
1
- /**
2
- * Normalize chunk text. Exactly three steps, in this order. Any deviation breaks
3
- * cross-language agreement with the Python implementation. See `spec/chunk-id.md`.
4
- */
5
- declare function normalize(text: string): string;
6
- /** Hash of the normalized text, so a judgment can describe itself without storing the text. */
7
- declare function textSha(text: string): string;
8
- interface ChunkIdInput {
9
- docUri: string;
10
- docRevision: string;
11
- ordinal: number;
12
- text: string;
13
- chunkerFingerprint: string;
14
- }
15
- /** Content-addressed chunk id: a label points at text, not at a position in a list. */
16
- declare function chunkId(input: ChunkIdInput): string;
17
-
18
1
  /** One relevance judgment. A judgments file is JSONL: one of these per line. */
19
2
  interface Judgment {
20
3
  query_id: string;
@@ -127,6 +110,128 @@ interface DriftResult {
127
110
  corpus_fingerprint: string | null;
128
111
  }
129
112
 
113
+ interface DriftOptions {
114
+ /** Corpus fingerprint to record when judgments carry none. */
115
+ corpusFingerprint?: string;
116
+ }
117
+ /**
118
+ * Classify every judgment against a live corpus.
119
+ *
120
+ * This is the question no other evaluation tool can answer: after you changed your chunker,
121
+ * which of your labels still mean what they meant when a human wrote them?
122
+ *
123
+ * - `VALID` the labeled chunk_id is still present
124
+ * - `RE_ANCHORABLE` the id is stale, but the exact text is still a live chunk
125
+ * - `MERGED` the text was absorbed into a coarser chunk; re-anchors safely
126
+ * - `SPLIT` the labeled text now spans two or more live chunks, so it needs re-judging
127
+ * - `ORPHANED` the text or its document is gone
128
+ */
129
+ declare function drift(judgments: Judgment[], corpus: Corpus, options?: DriftOptions): DriftResult;
130
+ interface FixResult {
131
+ judgments: Judgment[];
132
+ reanchored: number;
133
+ /** Labels left untouched because they need a human: SPLIT and ORPHANED. */
134
+ needsReview: DriftFinding[];
135
+ }
136
+ /**
137
+ * Re-anchor the recoverable labels onto their new chunk_ids and stamp the new fingerprint.
138
+ *
139
+ * RE_ANCHORABLE and MERGED are recoverable: in both cases the text a human judged is still
140
+ * there. SPLIT and ORPHANED are deliberately left alone, because guessing at them would
141
+ * silently fabricate ground truth, the exact failure this tool exists to expose.
142
+ */
143
+ declare function fix(judgments: Judgment[], result: DriftResult): FixResult;
144
+
145
+ /**
146
+ * A gate expression. Four forms, because absolute thresholds get tuned until they pass:
147
+ *
148
+ * recall@5:0.8 absolute floor
149
+ * recall@5:-0.02 delta against a baseline (regression tolerance)
150
+ * worst-stratum:recall@5:0.7 floor on the weakest query class
151
+ * faithfulness:ci-lower:0.8 floor on the lower confidence bound, so judge noise cannot pass
152
+ */
153
+ interface Gate {
154
+ raw: string;
155
+ kind: "absolute" | "delta" | "worst-stratum" | "ci-lower";
156
+ metric: string;
157
+ threshold: number;
158
+ }
159
+ declare function parseGate(expression: string): Gate;
160
+ /**
161
+ * The more serious of two verdicts: FAIL beats INDETERMINATE beats PASS.
162
+ *
163
+ * A gate only knows about the numbers it was pointed at. It cannot clear a finding it never
164
+ * looked at, such as a judgment set `validate` rejects, so a passing gate never upgrades a
165
+ * verdict that was already worse.
166
+ */
167
+ declare function worseStatus(a: Status, b: Status): Status;
168
+ interface EvaluateGatesOptions {
169
+ report: Report;
170
+ baseline?: Report | undefined;
171
+ gates: Gate[];
172
+ }
173
+ declare function evaluateGates({ report, baseline, gates }: EvaluateGatesOptions): {
174
+ status: Status;
175
+ results: GateResult[];
176
+ reasons: string[];
177
+ };
178
+
179
+ /**
180
+ * Normalize chunk text. Exactly three steps, in this order. Any deviation breaks
181
+ * cross-language agreement with the Python implementation. See `spec/chunk-id.md`.
182
+ */
183
+ declare function normalize(text: string): string;
184
+ /** Hash of the normalized text, so a judgment can describe itself without storing the text. */
185
+ declare function textSha(text: string): string;
186
+ interface ChunkIdInput {
187
+ docUri: string;
188
+ docRevision: string;
189
+ ordinal: number;
190
+ text: string;
191
+ chunkerFingerprint: string;
192
+ }
193
+ /** Content-addressed chunk id: a label points at text, not at a position in a list. */
194
+ declare function chunkId(input: ChunkIdInput): string;
195
+
196
+ declare function parseJudgments(content: string, label?: string): Judgment[];
197
+ /**
198
+ * Parse a run JSONL file.
199
+ *
200
+ * The run is the one input that used to be taken on trust, and an unchecked run is how a
201
+ * metric goes out of range: a ranking holding a non-string, or two entries claiming the same
202
+ * query, produce numbers that mean nothing and say nothing about it.
203
+ *
204
+ * Two entries for one query are rejected rather than resolved, because the file no longer says
205
+ * what the ranking for that query is, and picking one silently is a guess. A key repeated
206
+ * *within* one ranking is a different thing: the ranking is still unambiguous, so it is
207
+ * accepted here and counted once by `score`, which reports that it did.
208
+ */
209
+ declare function parseRun(content: string, label?: string): RunEntry[];
210
+ declare function serializeJudgments(judgments: Judgment[]): string;
211
+ declare function parseCorpus(content: string, label?: string): Corpus;
212
+ type Severity = "error" | "warning";
213
+ interface ValidationIssue {
214
+ severity: Severity;
215
+ code: string;
216
+ message: string;
217
+ }
218
+ interface ValidationResult {
219
+ issues: ValidationIssue[];
220
+ queries: number;
221
+ labels: number;
222
+ humanLabels: number;
223
+ syntheticLabels: number;
224
+ fingerprint: string | null;
225
+ strata: Record<string, number>;
226
+ ok: boolean;
227
+ }
228
+ /**
229
+ * Structural and statistical sanity checks. The warnings matter as much as the errors: a
230
+ * judgment set with no positives, no human labels, or a two-query stratum will produce
231
+ * confident-looking numbers that mean nothing.
232
+ */
233
+ declare function validate(judgments: Judgment[]): ValidationResult;
234
+
130
235
  interface MetricOptions {
131
236
  /** Cutoff for @k metrics. */
132
237
  k?: number;
@@ -230,45 +335,6 @@ declare function worstStratum(perStratum: Record<string, StratumScore>, metric:
230
335
  n: number;
231
336
  } | null;
232
337
 
233
- declare function parseJudgments(content: string, label?: string): Judgment[];
234
- /**
235
- * Parse a run JSONL file.
236
- *
237
- * The run is the one input that used to be taken on trust, and an unchecked run is how a
238
- * metric goes out of range: a ranking holding a non-string, or two entries claiming the same
239
- * query, produce numbers that mean nothing and say nothing about it.
240
- *
241
- * Two entries for one query are rejected rather than resolved, because the file no longer says
242
- * what the ranking for that query is, and picking one silently is a guess. A key repeated
243
- * *within* one ranking is a different thing: the ranking is still unambiguous, so it is
244
- * accepted here and counted once by `score`, which reports that it did.
245
- */
246
- declare function parseRun(content: string, label?: string): RunEntry[];
247
- declare function serializeJudgments(judgments: Judgment[]): string;
248
- declare function parseCorpus(content: string, label?: string): Corpus;
249
- type Severity = "error" | "warning";
250
- interface ValidationIssue {
251
- severity: Severity;
252
- code: string;
253
- message: string;
254
- }
255
- interface ValidationResult {
256
- issues: ValidationIssue[];
257
- queries: number;
258
- labels: number;
259
- humanLabels: number;
260
- syntheticLabels: number;
261
- fingerprint: string | null;
262
- strata: Record<string, number>;
263
- ok: boolean;
264
- }
265
- /**
266
- * Structural and statistical sanity checks. The warnings matter as much as the errors: a
267
- * judgment set with no positives, no human labels, or a two-query stratum will produce
268
- * confident-looking numbers that mean nothing.
269
- */
270
- declare function validate(judgments: Judgment[]): ValidationResult;
271
-
272
338
  /**
273
339
  * TREC qrels: `query_id iteration doc_id relevance`, whitespace separated. Thirty years of
274
340
  * tooling reads this (trec_eval, ir_measures, BEIR, ir_datasets), which is why judgments are
@@ -295,74 +361,8 @@ declare function fromQrels(content: string, options?: FromQrelsOptions): Judgmen
295
361
  declare function toTrecRun(run: RunEntry[], runName?: string): string;
296
362
  declare function fromTrecRun(content: string): RunEntry[];
297
363
 
298
- interface DriftOptions {
299
- /** Corpus fingerprint to record when judgments carry none. */
300
- corpusFingerprint?: string;
301
- }
302
- /**
303
- * Classify every judgment against a live corpus.
304
- *
305
- * This is the question no other evaluation tool can answer: after you changed your chunker,
306
- * which of your labels still mean what they meant when a human wrote them?
307
- *
308
- * - `VALID` the labeled chunk_id is still present
309
- * - `RE_ANCHORABLE` the id is stale, but the exact text is still a live chunk
310
- * - `MERGED` the text was absorbed into a coarser chunk; re-anchors safely
311
- * - `SPLIT` the labeled text now spans two or more live chunks, so it needs re-judging
312
- * - `ORPHANED` the text or its document is gone
313
- */
314
- declare function drift(judgments: Judgment[], corpus: Corpus, options?: DriftOptions): DriftResult;
315
- interface FixResult {
316
- judgments: Judgment[];
317
- reanchored: number;
318
- /** Labels left untouched because they need a human: SPLIT and ORPHANED. */
319
- needsReview: DriftFinding[];
320
- }
321
- /**
322
- * Re-anchor the recoverable labels onto their new chunk_ids and stamp the new fingerprint.
323
- *
324
- * RE_ANCHORABLE and MERGED are recoverable: in both cases the text a human judged is still
325
- * there. SPLIT and ORPHANED are deliberately left alone, because guessing at them would
326
- * silently fabricate ground truth, the exact failure this tool exists to expose.
327
- */
328
- declare function fix(judgments: Judgment[], result: DriftResult): FixResult;
329
-
330
- /**
331
- * A gate expression. Four forms, because absolute thresholds get tuned until they pass:
332
- *
333
- * recall@5:0.8 absolute floor
334
- * recall@5:-0.02 delta against a baseline (regression tolerance)
335
- * worst-stratum:recall@5:0.7 floor on the weakest query class
336
- * faithfulness:ci-lower:0.8 floor on the lower confidence bound, so judge noise cannot pass
337
- */
338
- interface Gate {
339
- raw: string;
340
- kind: "absolute" | "delta" | "worst-stratum" | "ci-lower";
341
- metric: string;
342
- threshold: number;
343
- }
344
- declare function parseGate(expression: string): Gate;
345
- /**
346
- * The more serious of two verdicts: FAIL beats INDETERMINATE beats PASS.
347
- *
348
- * A gate only knows about the numbers it was pointed at. It cannot clear a finding it never
349
- * looked at, such as a judgment set `validate` rejects, so a passing gate never upgrades a
350
- * verdict that was already worse.
351
- */
352
- declare function worseStatus(a: Status, b: Status): Status;
353
- interface EvaluateGatesOptions {
354
- report: Report;
355
- baseline?: Report | undefined;
356
- gates: Gate[];
357
- }
358
- declare function evaluateGates({ report, baseline, gates }: EvaluateGatesOptions): {
359
- status: Status;
360
- results: GateResult[];
361
- reasons: string[];
362
- };
363
-
364
364
  declare const TOOL_NAME = "retrieval-eval";
365
- declare const TOOL_VERSION = "0.1.0";
365
+ declare const TOOL_VERSION = "0.1.1";
366
366
  declare const SPEC_VERSION: "1";
367
367
  interface BuildReportOptions extends MetricOptions {
368
368
  judgments: Judgment[];
package/dist/index.js CHANGED
@@ -21,6 +21,144 @@ function chunkId(input) {
21
21
  return `c1:${h128(canonical)}`;
22
22
  }
23
23
 
24
+ // src/drift.ts
25
+ function buildIndex(corpus) {
26
+ const byChunkId = /* @__PURE__ */ new Map();
27
+ const byTextSha = /* @__PURE__ */ new Map();
28
+ const byDoc = /* @__PURE__ */ new Map();
29
+ for (const chunk of corpus.chunks) {
30
+ byChunkId.set(chunk.chunk_id, chunk);
31
+ const sha = chunk.text_sha ?? (chunk.text !== void 0 ? textSha(chunk.text) : void 0);
32
+ if (sha) {
33
+ const list = byTextSha.get(sha);
34
+ if (list) list.push(chunk);
35
+ else byTextSha.set(sha, [chunk]);
36
+ }
37
+ const docList = byDoc.get(chunk.doc_uri);
38
+ if (docList) docList.push(chunk);
39
+ else byDoc.set(chunk.doc_uri, [chunk]);
40
+ }
41
+ for (const list of byDoc.values()) {
42
+ list.sort((a, b) => (a.ordinal ?? 0) - (b.ordinal ?? 0));
43
+ }
44
+ return { byChunkId, byTextSha, byDoc };
45
+ }
46
+ function findSplit(labeledText, docChunks) {
47
+ const haystack = normalize(labeledText);
48
+ if (haystack === "") return null;
49
+ const covering = docChunks.filter((chunk) => {
50
+ if (chunk.text === void 0) return false;
51
+ const needle = normalize(chunk.text);
52
+ return needle !== "" && haystack.includes(needle);
53
+ });
54
+ return covering.length >= 2 ? covering : null;
55
+ }
56
+ function findMerged(labeledText, docChunks) {
57
+ const needle = normalize(labeledText);
58
+ if (needle === "") return null;
59
+ const containing = docChunks.filter(
60
+ (chunk) => chunk.text !== void 0 && normalize(chunk.text).includes(needle)
61
+ );
62
+ if (containing.length === 0) return null;
63
+ return containing.reduce(
64
+ (best, chunk) => normalize(chunk.text).length < normalize(best.text).length ? chunk : best
65
+ );
66
+ }
67
+ function drift(judgments, corpus, options = {}) {
68
+ const index = buildIndex(corpus);
69
+ const findings = [];
70
+ const fingerprints = /* @__PURE__ */ new Set();
71
+ for (const j of judgments) {
72
+ if (j.corpus_fingerprint) fingerprints.add(j.corpus_fingerprint);
73
+ const finding = {
74
+ query_id: j.query_id,
75
+ doc_uri: j.doc_uri,
76
+ status: "ORPHANED"
77
+ };
78
+ if (j.chunk_id !== void 0) finding.chunk_id = j.chunk_id;
79
+ const sha = j.text_sha ?? (j.chunk_text !== void 0 ? textSha(j.chunk_text) : void 0);
80
+ const docChunks = index.byDoc.get(j.doc_uri) ?? [];
81
+ if (j.chunk_id === void 0) {
82
+ finding.status = docChunks.length > 0 ? "VALID" : "ORPHANED";
83
+ findings.push(finding);
84
+ continue;
85
+ }
86
+ if (index.byChunkId.has(j.chunk_id)) {
87
+ finding.status = "VALID";
88
+ findings.push(finding);
89
+ continue;
90
+ }
91
+ if (sha) {
92
+ const matches = index.byTextSha.get(sha) ?? [];
93
+ const sameDoc = matches.filter((chunk) => chunk.doc_uri === j.doc_uri);
94
+ const target = sameDoc[0] ?? matches[0];
95
+ if (target) {
96
+ finding.status = "RE_ANCHORABLE";
97
+ finding.reanchor_to = target.chunk_id;
98
+ findings.push(finding);
99
+ continue;
100
+ }
101
+ }
102
+ if (j.chunk_text !== void 0) {
103
+ const split = findSplit(j.chunk_text, docChunks);
104
+ if (split) {
105
+ finding.status = "SPLIT";
106
+ finding.split_into = split.map((chunk) => chunk.chunk_id);
107
+ findings.push(finding);
108
+ continue;
109
+ }
110
+ const merged = findMerged(j.chunk_text, docChunks);
111
+ if (merged) {
112
+ finding.status = "MERGED";
113
+ finding.reanchor_to = merged.chunk_id;
114
+ findings.push(finding);
115
+ continue;
116
+ }
117
+ }
118
+ findings.push(finding);
119
+ }
120
+ const count = (status) => findings.filter((f) => f.status === status).length;
121
+ const valid = count("VALID");
122
+ const total = findings.length;
123
+ return {
124
+ findings,
125
+ summary: {
126
+ valid,
127
+ re_anchorable: count("RE_ANCHORABLE"),
128
+ merged: count("MERGED"),
129
+ split: count("SPLIT"),
130
+ orphaned: count("ORPHANED"),
131
+ invalid_ratio: total === 0 ? 0 : Math.round((total - valid) / total * 1e12) / 1e12
132
+ },
133
+ judgments_fingerprint: fingerprints.size === 1 ? [...fingerprints][0] : null,
134
+ corpus_fingerprint: corpus.corpus_fingerprint ?? options.corpusFingerprint ?? null
135
+ };
136
+ }
137
+ function fix(judgments, result) {
138
+ const byQueryAndChunk = /* @__PURE__ */ new Map();
139
+ for (const finding of result.findings) {
140
+ byQueryAndChunk.set(`${finding.query_id}\0${finding.chunk_id ?? ""}`, finding);
141
+ }
142
+ let reanchored = 0;
143
+ const needsReview = [];
144
+ const out = judgments.map((j) => {
145
+ const finding = byQueryAndChunk.get(`${j.query_id}\0${j.chunk_id ?? ""}`);
146
+ if (!finding) return j;
147
+ if ((finding.status === "RE_ANCHORABLE" || finding.status === "MERGED") && finding.reanchor_to) {
148
+ reanchored++;
149
+ const next = { ...j, chunk_id: finding.reanchor_to };
150
+ if (result.corpus_fingerprint) next.corpus_fingerprint = result.corpus_fingerprint;
151
+ return next;
152
+ }
153
+ if (finding.status === "SPLIT" || finding.status === "ORPHANED") needsReview.push(finding);
154
+ if (finding.status === "VALID" && result.corpus_fingerprint) {
155
+ return { ...j, corpus_fingerprint: result.corpus_fingerprint };
156
+ }
157
+ return j;
158
+ });
159
+ return { judgments: out, reanchored, needsReview };
160
+ }
161
+
24
162
  // src/metrics.ts
25
163
  function judgmentKey(j) {
26
164
  return j.chunk_id ?? j.doc_uri;
@@ -310,72 +448,210 @@ function worstStratum(perStratum, metric) {
310
448
  return worst;
311
449
  }
312
450
 
313
- // src/judgments.ts
314
- function parseJsonl(content, label) {
315
- const out = [];
316
- const lines = content.split("\n");
317
- for (let i = 0; i < lines.length; i++) {
318
- const line = lines[i].trim();
319
- if (line === "" || line.startsWith("//")) continue;
320
- try {
321
- out.push({ value: JSON.parse(line), line: i + 1 });
322
- } catch (error) {
323
- throw new Error(`${label}:${i + 1}: invalid JSON, ${error.message}`);
324
- }
451
+ // src/gate.ts
452
+ function parseGate(expression) {
453
+ const parts = expression.split(":");
454
+ if (parts[0] === "worst-stratum") {
455
+ if (parts.length !== 3)
456
+ throw new Error(`gate '${expression}': expected worst-stratum:<metric>:<floor>`);
457
+ return {
458
+ raw: expression,
459
+ kind: "worst-stratum",
460
+ metric: parts[1],
461
+ threshold: parseNumber(parts[2], expression)
462
+ };
325
463
  }
326
- return out;
327
- }
328
- function parseJudgments(content, label = "judgments") {
329
- const rows = parseJsonl(content, label);
330
- for (const { value, line } of rows) {
331
- if (typeof value.query_id !== "string" || value.query_id === "")
332
- throw new Error(`${label}:${line}: missing query_id`);
333
- if (typeof value.doc_uri !== "string" || value.doc_uri === "")
334
- throw new Error(`${label}:${line}: missing doc_uri`);
335
- if (!Number.isInteger(value.relevance) || value.relevance < 0)
336
- throw new Error(`${label}:${line}: relevance must be a non-negative integer`);
464
+ if (parts.length === 3 && parts[1] === "ci-lower") {
465
+ return {
466
+ raw: expression,
467
+ kind: "ci-lower",
468
+ metric: parts[0],
469
+ threshold: parseNumber(parts[2], expression)
470
+ };
337
471
  }
338
- return rows.map((row) => row.value);
472
+ if (parts.length !== 2) throw new Error(`gate '${expression}': expected <metric>:<threshold>`);
473
+ const value = parts[1];
474
+ return {
475
+ raw: expression,
476
+ kind: value.startsWith("-") || value.startsWith("+") ? "delta" : "absolute",
477
+ metric: parts[0],
478
+ threshold: parseNumber(value, expression)
479
+ };
339
480
  }
340
- function parseRun(content, label = "run") {
341
- const rows = parseJsonl(content, label);
342
- const firstSeen = /* @__PURE__ */ new Map();
343
- for (const { value, line } of rows) {
344
- if (typeof value.query_id !== "string" || value.query_id === "")
345
- throw new Error(`${label}:${line}: missing query_id`);
346
- if (!Array.isArray(value.ranking))
347
- throw new Error(`${label}:${line}: ranking must be an array`);
348
- for (let i = 0; i < value.ranking.length; i++) {
349
- const key = value.ranking[i];
350
- if (typeof key !== "string" || key === "")
351
- throw new Error(`${label}:${line}: ranking[${i}] must be a non-empty string`);
481
+ var NUMBER = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
482
+ function parseNumber(value, expression) {
483
+ if (!NUMBER.test(value)) throw new Error(`gate '${expression}': '${value}' is not a number`);
484
+ return Number.parseFloat(value);
485
+ }
486
+ function worseStatus(a, b) {
487
+ if (a === "FAIL" || b === "FAIL") return "FAIL";
488
+ if (a === "INDETERMINATE" || b === "INDETERMINATE") return "INDETERMINATE";
489
+ return "PASS";
490
+ }
491
+ function evaluateGates({ report, baseline, gates }) {
492
+ const results = [];
493
+ const reasons = [];
494
+ for (const gate of gates) {
495
+ const result = evaluateGate(gate, report, baseline);
496
+ results.push(result);
497
+ if (result.status === "FAIL" || result.status === "INDETERMINATE") {
498
+ reasons.push(describe(gate, result, report));
352
499
  }
353
- const previous = firstSeen.get(value.query_id);
354
- if (previous !== void 0)
355
- throw new Error(
356
- `${label}:${line}: duplicate entry for query ${value.query_id}, already on line ${previous}`
357
- );
358
- firstSeen.set(value.query_id, line);
359
500
  }
360
- return rows.map((row) => row.value);
501
+ const status = results.some((r) => r.status === "FAIL") ? "FAIL" : results.some((r) => r.status === "INDETERMINATE") ? "INDETERMINATE" : "PASS";
502
+ return { status, results, reasons };
361
503
  }
362
- function byCodePoint(a, b) {
363
- const left = [...a];
364
- const right = [...b];
365
- for (let i = 0; i < Math.min(left.length, right.length); i++) {
366
- const difference = (left[i].codePointAt(0) ?? 0) - (right[i].codePointAt(0) ?? 0);
367
- if (difference !== 0) return difference;
504
+ function evaluateGate(gate, report, baseline) {
505
+ if (gate.kind === "worst-stratum") {
506
+ if (!report.per_stratum) {
507
+ return { expression: gate.raw, status: "INDETERMINATE" };
508
+ }
509
+ const worst = worstStratum(report.per_stratum, gate.metric);
510
+ if (!worst) return { expression: gate.raw, status: "INDETERMINATE" };
511
+ return {
512
+ expression: gate.raw,
513
+ status: worst.value >= gate.threshold ? "PASS" : "FAIL",
514
+ observed: worst.value
515
+ };
368
516
  }
369
- return left.length - right.length;
370
- }
371
- var FIELD_ORDER = [
372
- "query_id",
373
- "query",
374
- "doc_uri",
375
- "chunk_id",
376
- "text_sha",
377
- "chunk_text",
378
- "relevance",
517
+ const measurement = report.metrics[gate.metric];
518
+ if (!measurement) return { expression: gate.raw, status: "INDETERMINATE" };
519
+ if (gate.kind === "ci-lower") {
520
+ if (!measurement.ci) {
521
+ return { expression: gate.raw, status: "INDETERMINATE", observed: measurement.value };
522
+ }
523
+ const lower = measurement.ci[0];
524
+ return {
525
+ expression: gate.raw,
526
+ status: lower >= gate.threshold ? "PASS" : "FAIL",
527
+ observed: lower
528
+ };
529
+ }
530
+ if (gate.kind === "delta") {
531
+ const previous = baseline?.metrics[gate.metric];
532
+ if (!previous) {
533
+ return {
534
+ expression: gate.raw,
535
+ status: "INDETERMINATE",
536
+ observed: measurement.value,
537
+ baseline: null
538
+ };
539
+ }
540
+ const delta = measurement.value - previous.value;
541
+ return {
542
+ expression: gate.raw,
543
+ status: delta >= gate.threshold ? "PASS" : "FAIL",
544
+ observed: measurement.value,
545
+ baseline: previous.value
546
+ };
547
+ }
548
+ return {
549
+ expression: gate.raw,
550
+ status: measurement.value >= gate.threshold ? "PASS" : "FAIL",
551
+ observed: measurement.value
552
+ };
553
+ }
554
+ function describe(gate, result, report) {
555
+ if (result.status === "INDETERMINATE") {
556
+ const nothingScored = report.judgments.queries_scored === 0 && result.observed === void 0;
557
+ if (nothingScored && gate.kind !== "worst-stratum") {
558
+ return `${gate.raw}: no query was scored, so '${gate.metric}' was not computed`;
559
+ }
560
+ if (gate.kind === "ci-lower") {
561
+ return `${gate.raw}: no confidence interval on '${gate.metric}', sample it more than once`;
562
+ }
563
+ if (gate.kind === "delta") return `${gate.raw}: no baseline value for '${gate.metric}'`;
564
+ if (gate.kind === "worst-stratum") {
565
+ const strata = Object.values(report.per_stratum ?? {});
566
+ if (strata.length > 0 && strata.every((stratum) => stratum.n === 0)) {
567
+ return `${gate.raw}: no stratum was scored for '${gate.metric}'`;
568
+ }
569
+ return `${gate.raw}: no per-stratum data for '${gate.metric}'`;
570
+ }
571
+ return `${gate.raw}: metric '${gate.metric}' not present in the report`;
572
+ }
573
+ if (gate.kind === "delta") {
574
+ const from = result.baseline ?? 0;
575
+ const to = result.observed ?? 0;
576
+ const change = to - from;
577
+ const direction = change < 0 ? `fell ${(-change).toFixed(4)}` : `rose ${change.toFixed(4)}`;
578
+ return `${gate.metric} ${direction}, from ${from.toFixed(4)} to ${to.toFixed(4)}, outside ${gate.threshold}`;
579
+ }
580
+ if (gate.kind === "worst-stratum") {
581
+ return `worst stratum ${gate.metric} is ${(result.observed ?? 0).toFixed(4)}, below ${gate.threshold}`;
582
+ }
583
+ if (gate.kind === "ci-lower") {
584
+ return `${gate.metric} lower bound is ${(result.observed ?? 0).toFixed(4)}, below ${gate.threshold}`;
585
+ }
586
+ return `${gate.metric} is ${(result.observed ?? 0).toFixed(4)}, below ${gate.threshold}`;
587
+ }
588
+
589
+ // src/judgments.ts
590
+ function parseJsonl(content, label) {
591
+ const out = [];
592
+ const lines = content.split("\n");
593
+ for (let i = 0; i < lines.length; i++) {
594
+ const line = lines[i].trim();
595
+ if (line === "" || line.startsWith("//")) continue;
596
+ try {
597
+ out.push({ value: JSON.parse(line), line: i + 1 });
598
+ } catch (error) {
599
+ throw new Error(`${label}:${i + 1}: invalid JSON, ${error.message}`);
600
+ }
601
+ }
602
+ return out;
603
+ }
604
+ function parseJudgments(content, label = "judgments") {
605
+ const rows = parseJsonl(content, label);
606
+ for (const { value, line } of rows) {
607
+ if (typeof value.query_id !== "string" || value.query_id === "")
608
+ throw new Error(`${label}:${line}: missing query_id`);
609
+ if (typeof value.doc_uri !== "string" || value.doc_uri === "")
610
+ throw new Error(`${label}:${line}: missing doc_uri`);
611
+ if (!Number.isInteger(value.relevance) || value.relevance < 0)
612
+ throw new Error(`${label}:${line}: relevance must be a non-negative integer`);
613
+ }
614
+ return rows.map((row) => row.value);
615
+ }
616
+ function parseRun(content, label = "run") {
617
+ const rows = parseJsonl(content, label);
618
+ const firstSeen = /* @__PURE__ */ new Map();
619
+ for (const { value, line } of rows) {
620
+ if (typeof value.query_id !== "string" || value.query_id === "")
621
+ throw new Error(`${label}:${line}: missing query_id`);
622
+ if (!Array.isArray(value.ranking))
623
+ throw new Error(`${label}:${line}: ranking must be an array`);
624
+ for (let i = 0; i < value.ranking.length; i++) {
625
+ const key = value.ranking[i];
626
+ if (typeof key !== "string" || key === "")
627
+ throw new Error(`${label}:${line}: ranking[${i}] must be a non-empty string`);
628
+ }
629
+ const previous = firstSeen.get(value.query_id);
630
+ if (previous !== void 0)
631
+ throw new Error(
632
+ `${label}:${line}: duplicate entry for query ${value.query_id}, already on line ${previous}`
633
+ );
634
+ firstSeen.set(value.query_id, line);
635
+ }
636
+ return rows.map((row) => row.value);
637
+ }
638
+ function byCodePoint(a, b) {
639
+ const left = [...a];
640
+ const right = [...b];
641
+ for (let i = 0; i < Math.min(left.length, right.length); i++) {
642
+ const difference = (left[i].codePointAt(0) ?? 0) - (right[i].codePointAt(0) ?? 0);
643
+ if (difference !== 0) return difference;
644
+ }
645
+ return left.length - right.length;
646
+ }
647
+ var FIELD_ORDER = [
648
+ "query_id",
649
+ "query",
650
+ "doc_uri",
651
+ "chunk_id",
652
+ "text_sha",
653
+ "chunk_text",
654
+ "relevance",
379
655
  "corpus_fingerprint",
380
656
  "labeled_by",
381
657
  "labeled_at",
@@ -589,285 +865,9 @@ function fromTrecRun(content) {
589
865
  }));
590
866
  }
591
867
 
592
- // src/drift.ts
593
- function buildIndex(corpus) {
594
- const byChunkId = /* @__PURE__ */ new Map();
595
- const byTextSha = /* @__PURE__ */ new Map();
596
- const byDoc = /* @__PURE__ */ new Map();
597
- for (const chunk of corpus.chunks) {
598
- byChunkId.set(chunk.chunk_id, chunk);
599
- const sha = chunk.text_sha ?? (chunk.text !== void 0 ? textSha(chunk.text) : void 0);
600
- if (sha) {
601
- const list = byTextSha.get(sha);
602
- if (list) list.push(chunk);
603
- else byTextSha.set(sha, [chunk]);
604
- }
605
- const docList = byDoc.get(chunk.doc_uri);
606
- if (docList) docList.push(chunk);
607
- else byDoc.set(chunk.doc_uri, [chunk]);
608
- }
609
- for (const list of byDoc.values()) {
610
- list.sort((a, b) => (a.ordinal ?? 0) - (b.ordinal ?? 0));
611
- }
612
- return { byChunkId, byTextSha, byDoc };
613
- }
614
- function findSplit(labeledText, docChunks) {
615
- const haystack = normalize(labeledText);
616
- if (haystack === "") return null;
617
- const covering = docChunks.filter((chunk) => {
618
- if (chunk.text === void 0) return false;
619
- const needle = normalize(chunk.text);
620
- return needle !== "" && haystack.includes(needle);
621
- });
622
- return covering.length >= 2 ? covering : null;
623
- }
624
- function findMerged(labeledText, docChunks) {
625
- const needle = normalize(labeledText);
626
- if (needle === "") return null;
627
- const containing = docChunks.filter(
628
- (chunk) => chunk.text !== void 0 && normalize(chunk.text).includes(needle)
629
- );
630
- if (containing.length === 0) return null;
631
- return containing.reduce(
632
- (best, chunk) => normalize(chunk.text).length < normalize(best.text).length ? chunk : best
633
- );
634
- }
635
- function drift(judgments, corpus, options = {}) {
636
- const index = buildIndex(corpus);
637
- const findings = [];
638
- const fingerprints = /* @__PURE__ */ new Set();
639
- for (const j of judgments) {
640
- if (j.corpus_fingerprint) fingerprints.add(j.corpus_fingerprint);
641
- const finding = {
642
- query_id: j.query_id,
643
- doc_uri: j.doc_uri,
644
- status: "ORPHANED"
645
- };
646
- if (j.chunk_id !== void 0) finding.chunk_id = j.chunk_id;
647
- const sha = j.text_sha ?? (j.chunk_text !== void 0 ? textSha(j.chunk_text) : void 0);
648
- const docChunks = index.byDoc.get(j.doc_uri) ?? [];
649
- if (j.chunk_id === void 0) {
650
- finding.status = docChunks.length > 0 ? "VALID" : "ORPHANED";
651
- findings.push(finding);
652
- continue;
653
- }
654
- if (index.byChunkId.has(j.chunk_id)) {
655
- finding.status = "VALID";
656
- findings.push(finding);
657
- continue;
658
- }
659
- if (sha) {
660
- const matches = index.byTextSha.get(sha) ?? [];
661
- const sameDoc = matches.filter((chunk) => chunk.doc_uri === j.doc_uri);
662
- const target = sameDoc[0] ?? matches[0];
663
- if (target) {
664
- finding.status = "RE_ANCHORABLE";
665
- finding.reanchor_to = target.chunk_id;
666
- findings.push(finding);
667
- continue;
668
- }
669
- }
670
- if (j.chunk_text !== void 0) {
671
- const split = findSplit(j.chunk_text, docChunks);
672
- if (split) {
673
- finding.status = "SPLIT";
674
- finding.split_into = split.map((chunk) => chunk.chunk_id);
675
- findings.push(finding);
676
- continue;
677
- }
678
- const merged = findMerged(j.chunk_text, docChunks);
679
- if (merged) {
680
- finding.status = "MERGED";
681
- finding.reanchor_to = merged.chunk_id;
682
- findings.push(finding);
683
- continue;
684
- }
685
- }
686
- findings.push(finding);
687
- }
688
- const count = (status) => findings.filter((f) => f.status === status).length;
689
- const valid = count("VALID");
690
- const total = findings.length;
691
- return {
692
- findings,
693
- summary: {
694
- valid,
695
- re_anchorable: count("RE_ANCHORABLE"),
696
- merged: count("MERGED"),
697
- split: count("SPLIT"),
698
- orphaned: count("ORPHANED"),
699
- invalid_ratio: total === 0 ? 0 : Math.round((total - valid) / total * 1e12) / 1e12
700
- },
701
- judgments_fingerprint: fingerprints.size === 1 ? [...fingerprints][0] : null,
702
- corpus_fingerprint: corpus.corpus_fingerprint ?? options.corpusFingerprint ?? null
703
- };
704
- }
705
- function fix(judgments, result) {
706
- const byQueryAndChunk = /* @__PURE__ */ new Map();
707
- for (const finding of result.findings) {
708
- byQueryAndChunk.set(`${finding.query_id}\0${finding.chunk_id ?? ""}`, finding);
709
- }
710
- let reanchored = 0;
711
- const needsReview = [];
712
- const out = judgments.map((j) => {
713
- const finding = byQueryAndChunk.get(`${j.query_id}\0${j.chunk_id ?? ""}`);
714
- if (!finding) return j;
715
- if ((finding.status === "RE_ANCHORABLE" || finding.status === "MERGED") && finding.reanchor_to) {
716
- reanchored++;
717
- const next = { ...j, chunk_id: finding.reanchor_to };
718
- if (result.corpus_fingerprint) next.corpus_fingerprint = result.corpus_fingerprint;
719
- return next;
720
- }
721
- if (finding.status === "SPLIT" || finding.status === "ORPHANED") needsReview.push(finding);
722
- if (finding.status === "VALID" && result.corpus_fingerprint) {
723
- return { ...j, corpus_fingerprint: result.corpus_fingerprint };
724
- }
725
- return j;
726
- });
727
- return { judgments: out, reanchored, needsReview };
728
- }
729
-
730
- // src/gate.ts
731
- function parseGate(expression) {
732
- const parts = expression.split(":");
733
- if (parts[0] === "worst-stratum") {
734
- if (parts.length !== 3)
735
- throw new Error(`gate '${expression}': expected worst-stratum:<metric>:<floor>`);
736
- return {
737
- raw: expression,
738
- kind: "worst-stratum",
739
- metric: parts[1],
740
- threshold: parseNumber(parts[2], expression)
741
- };
742
- }
743
- if (parts.length === 3 && parts[1] === "ci-lower") {
744
- return {
745
- raw: expression,
746
- kind: "ci-lower",
747
- metric: parts[0],
748
- threshold: parseNumber(parts[2], expression)
749
- };
750
- }
751
- if (parts.length !== 2) throw new Error(`gate '${expression}': expected <metric>:<threshold>`);
752
- const value = parts[1];
753
- return {
754
- raw: expression,
755
- kind: value.startsWith("-") || value.startsWith("+") ? "delta" : "absolute",
756
- metric: parts[0],
757
- threshold: parseNumber(value, expression)
758
- };
759
- }
760
- var NUMBER = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
761
- function parseNumber(value, expression) {
762
- if (!NUMBER.test(value)) throw new Error(`gate '${expression}': '${value}' is not a number`);
763
- return Number.parseFloat(value);
764
- }
765
- function worseStatus(a, b) {
766
- if (a === "FAIL" || b === "FAIL") return "FAIL";
767
- if (a === "INDETERMINATE" || b === "INDETERMINATE") return "INDETERMINATE";
768
- return "PASS";
769
- }
770
- function evaluateGates({ report, baseline, gates }) {
771
- const results = [];
772
- const reasons = [];
773
- for (const gate of gates) {
774
- const result = evaluateGate(gate, report, baseline);
775
- results.push(result);
776
- if (result.status === "FAIL" || result.status === "INDETERMINATE") {
777
- reasons.push(describe(gate, result, report));
778
- }
779
- }
780
- const status = results.some((r) => r.status === "FAIL") ? "FAIL" : results.some((r) => r.status === "INDETERMINATE") ? "INDETERMINATE" : "PASS";
781
- return { status, results, reasons };
782
- }
783
- function evaluateGate(gate, report, baseline) {
784
- if (gate.kind === "worst-stratum") {
785
- if (!report.per_stratum) {
786
- return { expression: gate.raw, status: "INDETERMINATE" };
787
- }
788
- const worst = worstStratum(report.per_stratum, gate.metric);
789
- if (!worst) return { expression: gate.raw, status: "INDETERMINATE" };
790
- return {
791
- expression: gate.raw,
792
- status: worst.value >= gate.threshold ? "PASS" : "FAIL",
793
- observed: worst.value
794
- };
795
- }
796
- const measurement = report.metrics[gate.metric];
797
- if (!measurement) return { expression: gate.raw, status: "INDETERMINATE" };
798
- if (gate.kind === "ci-lower") {
799
- if (!measurement.ci) {
800
- return { expression: gate.raw, status: "INDETERMINATE", observed: measurement.value };
801
- }
802
- const lower = measurement.ci[0];
803
- return {
804
- expression: gate.raw,
805
- status: lower >= gate.threshold ? "PASS" : "FAIL",
806
- observed: lower
807
- };
808
- }
809
- if (gate.kind === "delta") {
810
- const previous = baseline?.metrics[gate.metric];
811
- if (!previous) {
812
- return {
813
- expression: gate.raw,
814
- status: "INDETERMINATE",
815
- observed: measurement.value,
816
- baseline: null
817
- };
818
- }
819
- const delta = measurement.value - previous.value;
820
- return {
821
- expression: gate.raw,
822
- status: delta >= gate.threshold ? "PASS" : "FAIL",
823
- observed: measurement.value,
824
- baseline: previous.value
825
- };
826
- }
827
- return {
828
- expression: gate.raw,
829
- status: measurement.value >= gate.threshold ? "PASS" : "FAIL",
830
- observed: measurement.value
831
- };
832
- }
833
- function describe(gate, result, report) {
834
- if (result.status === "INDETERMINATE") {
835
- const nothingScored = report.judgments.queries_scored === 0 && result.observed === void 0;
836
- if (nothingScored && gate.kind !== "worst-stratum") {
837
- return `${gate.raw}: no query was scored, so '${gate.metric}' was not computed`;
838
- }
839
- if (gate.kind === "ci-lower") {
840
- return `${gate.raw}: no confidence interval on '${gate.metric}', sample it more than once`;
841
- }
842
- if (gate.kind === "delta") return `${gate.raw}: no baseline value for '${gate.metric}'`;
843
- if (gate.kind === "worst-stratum") {
844
- const strata = Object.values(report.per_stratum ?? {});
845
- if (strata.length > 0 && strata.every((stratum) => stratum.n === 0)) {
846
- return `${gate.raw}: no stratum was scored for '${gate.metric}'`;
847
- }
848
- return `${gate.raw}: no per-stratum data for '${gate.metric}'`;
849
- }
850
- return `${gate.raw}: metric '${gate.metric}' not present in the report`;
851
- }
852
- if (gate.kind === "delta") {
853
- const from = result.baseline ?? 0;
854
- const to = result.observed ?? 0;
855
- const change = to - from;
856
- const direction = change < 0 ? `fell ${(-change).toFixed(4)}` : `rose ${change.toFixed(4)}`;
857
- return `${gate.metric} ${direction}, from ${from.toFixed(4)} to ${to.toFixed(4)}, outside ${gate.threshold}`;
858
- }
859
- if (gate.kind === "worst-stratum") {
860
- return `worst stratum ${gate.metric} is ${(result.observed ?? 0).toFixed(4)}, below ${gate.threshold}`;
861
- }
862
- if (gate.kind === "ci-lower") {
863
- return `${gate.metric} lower bound is ${(result.observed ?? 0).toFixed(4)}, below ${gate.threshold}`;
864
- }
865
- return `${gate.metric} is ${(result.observed ?? 0).toFixed(4)}, below ${gate.threshold}`;
866
- }
867
-
868
868
  // src/report.ts
869
869
  var TOOL_NAME = "retrieval-eval";
870
- var TOOL_VERSION = "0.1.0";
870
+ var TOOL_VERSION = "0.1.1";
871
871
  var SPEC_VERSION = "1";
872
872
  function buildReport(options) {
873
873
  const { judgments, run, corpus, driftResult } = options;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "retrieval-eval",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Your RAG test suite decays every time you re-chunk. Drift detection that tells you which relevance labels are still true after a chunker or corpus change and re-anchors the ones it can, plus deterministic retrieval metrics and a portable, TREC qrels-compatible judgment format. Zero dependencies, no API key.",
5
5
  "keywords": [
6
6
  "rag",
@@ -41,7 +41,12 @@
41
41
  },
42
42
  "./package.json": "./package.json"
43
43
  },
44
- "files": ["dist", "spec", "README.md", "LICENSE"],
44
+ "files": [
45
+ "dist",
46
+ "spec",
47
+ "README.md",
48
+ "LICENSE"
49
+ ],
45
50
  "engines": {
46
51
  "node": ">=20.11"
47
52
  },
@@ -55,10 +60,11 @@
55
60
  },
56
61
  "devDependencies": {
57
62
  "@types/node": "^22.9.0",
58
- "@vitest/coverage-v8": "^2.1.4",
63
+ "@vitest/coverage-v8": "^4.1.11",
59
64
  "tsup": "^8.3.5",
60
- "typescript": "^5.6.3",
61
- "vitest": "^2.1.4"
65
+ "typescript": "^5.9.3",
66
+ "vite": "^8.3.0",
67
+ "vitest": "^4.1.11"
62
68
  },
63
69
  "repository": {
64
70
  "type": "git",