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 +1 -1
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +123 -123
- package/dist/index.js +336 -336
- package/package.json +11 -5
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.
|
|
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.
|
|
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.
|
|
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/
|
|
314
|
-
function
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
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
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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.
|
|
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.
|
|
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": [
|
|
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": "^
|
|
63
|
+
"@vitest/coverage-v8": "^4.1.11",
|
|
59
64
|
"tsup": "^8.3.5",
|
|
60
|
-
"typescript": "^5.
|
|
61
|
-
"
|
|
65
|
+
"typescript": "^5.9.3",
|
|
66
|
+
"vite": "^8.3.0",
|
|
67
|
+
"vitest": "^4.1.11"
|
|
62
68
|
},
|
|
63
69
|
"repository": {
|
|
64
70
|
"type": "git",
|