@tikoci/rosetta 0.8.6 → 0.8.8
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/package.json +1 -1
- package/src/browse.ts +5 -3
- package/src/eval/retrieval.ts +469 -0
- package/src/eval/self-supervised.ts +585 -0
- package/src/mcp-contract.test.ts +263 -0
- package/src/query.test.ts +43 -0
- package/src/query.ts +39 -2
- package/src/release.test.ts +11 -0
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 1 — Self-supervised retrieval evaluation runner.
|
|
3
|
+
*
|
|
4
|
+
* Auto-generates query→expected-page pairs from the live SQLite DB (section
|
|
5
|
+
* headings, property names, command paths, page titles), then scores searchAll()
|
|
6
|
+
* against them. No LLM cost, fully deterministic — a cheap "wide net" companion
|
|
7
|
+
* to Phase 0's hand-curated golden set.
|
|
8
|
+
*
|
|
9
|
+
* Modes:
|
|
10
|
+
* bun run src/eval/self-supervised.ts
|
|
11
|
+
* → run + print report + compare to baseline
|
|
12
|
+
* bun run src/eval/self-supervised.ts --json
|
|
13
|
+
* → run + emit JSON report on stdout
|
|
14
|
+
* bun run src/eval/self-supervised.ts --update-baseline
|
|
15
|
+
* → run + overwrite fixtures/eval/self-supervised-baseline.json
|
|
16
|
+
* bun run src/eval/self-supervised.ts --filter <strategy>
|
|
17
|
+
* → only run one strategy (section|property|cmd-path|title)
|
|
18
|
+
* bun run src/eval/self-supervised.ts --limit <N>
|
|
19
|
+
* → cap total queries (for fast iteration)
|
|
20
|
+
* bun run src/eval/self-supervised.ts --top-misses <N>
|
|
21
|
+
* → print top N highest-rank-but-still-missed queries
|
|
22
|
+
*
|
|
23
|
+
* Exit codes:
|
|
24
|
+
* 0 all metrics meet thresholds AND no regression vs baseline
|
|
25
|
+
* 1 threshold or baseline regression
|
|
26
|
+
* 2 runner error (DB missing, etc.)
|
|
27
|
+
*
|
|
28
|
+
* See BACKLOG.md "MCP Behavioral Testing — Phase 1" for design rationale.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
32
|
+
import { join } from "node:path";
|
|
33
|
+
|
|
34
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
type Strategy = "section" | "property" | "cmd-path" | "title";
|
|
37
|
+
|
|
38
|
+
type QueryCase = {
|
|
39
|
+
query: string;
|
|
40
|
+
expected_page_id: number;
|
|
41
|
+
source: Strategy;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type Thresholds = {
|
|
45
|
+
title_hit_at_5: number;
|
|
46
|
+
section_hit_at_10: number;
|
|
47
|
+
property_hit_at_10: number;
|
|
48
|
+
cmd_path_hit_at_5: number;
|
|
49
|
+
overall_mrr: number;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const THRESHOLDS: Thresholds = {
|
|
53
|
+
title_hit_at_5: 0.9,
|
|
54
|
+
section_hit_at_10: 0.65,
|
|
55
|
+
property_hit_at_10: 0.55,
|
|
56
|
+
cmd_path_hit_at_5: 0.7,
|
|
57
|
+
overall_mrr: 0.45,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
type QueryResult = {
|
|
61
|
+
query: string;
|
|
62
|
+
expected_page_id: number;
|
|
63
|
+
source: Strategy;
|
|
64
|
+
hit_at_1: number;
|
|
65
|
+
hit_at_5: number;
|
|
66
|
+
hit_at_10: number;
|
|
67
|
+
mrr: number;
|
|
68
|
+
rank: number | null; // null if not found in top 10
|
|
69
|
+
top_3_pages: { id: number; title: string }[];
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
type StrategyMetrics = {
|
|
73
|
+
count: number;
|
|
74
|
+
hit_at_1: number;
|
|
75
|
+
hit_at_5: number;
|
|
76
|
+
hit_at_10: number;
|
|
77
|
+
mrr: number;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
type Report = {
|
|
81
|
+
generated_at: string;
|
|
82
|
+
total_queries: number;
|
|
83
|
+
per_strategy: Record<Strategy, StrategyMetrics>;
|
|
84
|
+
overall: {
|
|
85
|
+
hit_at_1: number;
|
|
86
|
+
hit_at_5: number;
|
|
87
|
+
hit_at_10: number;
|
|
88
|
+
mrr: number;
|
|
89
|
+
};
|
|
90
|
+
results: QueryResult[];
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// ── Seeded RNG for deterministic sampling ──────────────────────────────────
|
|
94
|
+
|
|
95
|
+
function splitmix32(seed: number): () => number {
|
|
96
|
+
let a = seed;
|
|
97
|
+
return () => {
|
|
98
|
+
a |= 0;
|
|
99
|
+
a = (a + 0x9e3779b9) | 0;
|
|
100
|
+
let t = a ^ (a >>> 16);
|
|
101
|
+
t = Math.imul(t, 0x21f0aaad);
|
|
102
|
+
t = t ^ (t >>> 15);
|
|
103
|
+
t = Math.imul(t, 0x735a2d97);
|
|
104
|
+
t = t ^ (t >>> 15);
|
|
105
|
+
return (t >>> 0) / 4294967296;
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function shuffle<T>(arr: T[], rng: () => number): T[] {
|
|
110
|
+
const result = [...arr];
|
|
111
|
+
for (let i = result.length - 1; i > 0; i--) {
|
|
112
|
+
const j = Math.floor(rng() * (i + 1));
|
|
113
|
+
[result[i], result[j]] = [result[j] as T, result[i] as T];
|
|
114
|
+
}
|
|
115
|
+
return result;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── Query generation ───────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
const GENERIC_HEADINGS = new Set([
|
|
121
|
+
"Overview",
|
|
122
|
+
"Example",
|
|
123
|
+
"Examples",
|
|
124
|
+
"Properties",
|
|
125
|
+
"Notes",
|
|
126
|
+
"Description",
|
|
127
|
+
"Configuration",
|
|
128
|
+
"Introduction",
|
|
129
|
+
"Summary",
|
|
130
|
+
"See also",
|
|
131
|
+
"See Also",
|
|
132
|
+
"Usage",
|
|
133
|
+
"General",
|
|
134
|
+
"Basic",
|
|
135
|
+
"Advanced",
|
|
136
|
+
"Settings",
|
|
137
|
+
]);
|
|
138
|
+
|
|
139
|
+
const GENERIC_PROPERTIES = new Set(["disabled", "comment", "name"]);
|
|
140
|
+
|
|
141
|
+
async function generateQueries(
|
|
142
|
+
filter?: Strategy,
|
|
143
|
+
limit?: number,
|
|
144
|
+
): Promise<QueryCase[]> {
|
|
145
|
+
// Dynamic import to respect DB_PATH env var overrides (see extraction.instructions.md)
|
|
146
|
+
const { db } = await import("../db.ts");
|
|
147
|
+
|
|
148
|
+
const rng = splitmix32(0xc0ffee);
|
|
149
|
+
const queries: QueryCase[] = [];
|
|
150
|
+
|
|
151
|
+
const strategies: Strategy[] = filter
|
|
152
|
+
? [filter]
|
|
153
|
+
: ["section", "property", "cmd-path", "title"];
|
|
154
|
+
|
|
155
|
+
for (const strategy of strategies) {
|
|
156
|
+
let cases: QueryCase[] = [];
|
|
157
|
+
|
|
158
|
+
if (strategy === "section") {
|
|
159
|
+
// Target ~80 queries from section headings
|
|
160
|
+
const rows = db
|
|
161
|
+
.prepare(
|
|
162
|
+
`SELECT page_id, heading FROM sections
|
|
163
|
+
WHERE level IN (2, 3) AND length(heading) BETWEEN 6 AND 60
|
|
164
|
+
ORDER BY page_id`,
|
|
165
|
+
)
|
|
166
|
+
.all() as { page_id: number; heading: string }[];
|
|
167
|
+
|
|
168
|
+
const filtered = rows.filter((r) => {
|
|
169
|
+
const words = r.heading.trim().split(/\s+/);
|
|
170
|
+
if (words.length <= 1) return false;
|
|
171
|
+
if (GENERIC_HEADINGS.has(r.heading.trim())) return false;
|
|
172
|
+
return true;
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
cases = shuffle(filtered, rng)
|
|
176
|
+
.slice(0, limit ? Math.floor(limit / 4) : 80)
|
|
177
|
+
.map((r) => ({
|
|
178
|
+
query: r.heading,
|
|
179
|
+
expected_page_id: r.page_id,
|
|
180
|
+
source: "section" as Strategy,
|
|
181
|
+
}));
|
|
182
|
+
} else if (strategy === "property") {
|
|
183
|
+
// Target ~60 queries from property names
|
|
184
|
+
const rows = db
|
|
185
|
+
.prepare(
|
|
186
|
+
`SELECT DISTINCT page_id, name FROM properties
|
|
187
|
+
WHERE length(name) >= 4
|
|
188
|
+
ORDER BY name`,
|
|
189
|
+
)
|
|
190
|
+
.all() as { page_id: number; name: string }[];
|
|
191
|
+
|
|
192
|
+
const filtered = rows.filter((r) => !GENERIC_PROPERTIES.has(r.name));
|
|
193
|
+
|
|
194
|
+
cases = shuffle(filtered, rng)
|
|
195
|
+
.slice(0, limit ? Math.floor(limit / 4) : 60)
|
|
196
|
+
.map((r) => ({
|
|
197
|
+
query: r.name.includes("-") ? r.name : `${r.name} property`,
|
|
198
|
+
expected_page_id: r.page_id,
|
|
199
|
+
source: "property" as Strategy,
|
|
200
|
+
}));
|
|
201
|
+
} else if (strategy === "cmd-path") {
|
|
202
|
+
// Target ~30 queries from command paths (skip if table is small)
|
|
203
|
+
const commandsCount = (
|
|
204
|
+
db.prepare("SELECT COUNT(*) as c FROM commands").get() as { c: number }
|
|
205
|
+
).c;
|
|
206
|
+
|
|
207
|
+
if (commandsCount >= 1000) {
|
|
208
|
+
const rows = db
|
|
209
|
+
.prepare(
|
|
210
|
+
`SELECT page_id, path FROM commands
|
|
211
|
+
WHERE page_id IS NOT NULL AND type = 'dir'
|
|
212
|
+
ORDER BY path`,
|
|
213
|
+
)
|
|
214
|
+
.all() as { page_id: number; path: string }[];
|
|
215
|
+
|
|
216
|
+
cases = shuffle(rows, rng)
|
|
217
|
+
.slice(0, limit ? Math.floor(limit / 4) : 30)
|
|
218
|
+
.map((r) => ({
|
|
219
|
+
query: r.path,
|
|
220
|
+
expected_page_id: r.page_id,
|
|
221
|
+
source: "cmd-path" as Strategy,
|
|
222
|
+
}));
|
|
223
|
+
}
|
|
224
|
+
} else if (strategy === "title") {
|
|
225
|
+
// Target ~30 queries from page titles (3-6 words)
|
|
226
|
+
const rows = db
|
|
227
|
+
.prepare(
|
|
228
|
+
`SELECT id, title FROM pages
|
|
229
|
+
ORDER BY id`,
|
|
230
|
+
)
|
|
231
|
+
.all() as { id: number; title: string }[];
|
|
232
|
+
|
|
233
|
+
const filtered = rows.filter((r) => {
|
|
234
|
+
const words = r.title.trim().split(/\s+/);
|
|
235
|
+
return words.length >= 3 && words.length <= 6;
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
cases = shuffle(filtered, rng)
|
|
239
|
+
.slice(0, limit ? Math.floor(limit / 4) : 30)
|
|
240
|
+
.map((r) => ({
|
|
241
|
+
query: r.title,
|
|
242
|
+
expected_page_id: r.id,
|
|
243
|
+
source: "title" as Strategy,
|
|
244
|
+
}));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
queries.push(...cases);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Apply overall limit if specified
|
|
251
|
+
if (limit && queries.length > limit) {
|
|
252
|
+
return shuffle(queries, rng).slice(0, limit);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return queries;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── Evaluation ─────────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
async function evalQuery(q: QueryCase): Promise<QueryResult> {
|
|
261
|
+
const { searchAll } = await import("../query.ts");
|
|
262
|
+
|
|
263
|
+
const resp = searchAll(q.query, 10);
|
|
264
|
+
const pageIds = resp.pages.map((p) => p.id);
|
|
265
|
+
|
|
266
|
+
let rank: number | null = null;
|
|
267
|
+
for (let i = 0; i < pageIds.length; i++) {
|
|
268
|
+
if (pageIds[i] === q.expected_page_id) {
|
|
269
|
+
rank = i + 1;
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const hit_at_1 = rank === 1 ? 1 : 0;
|
|
275
|
+
const hit_at_5 = rank !== null && rank <= 5 ? 1 : 0;
|
|
276
|
+
const hit_at_10 = rank !== null && rank <= 10 ? 1 : 0;
|
|
277
|
+
const mrr = rank !== null ? 1 / rank : 0;
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
query: q.query,
|
|
281
|
+
expected_page_id: q.expected_page_id,
|
|
282
|
+
source: q.source,
|
|
283
|
+
hit_at_1,
|
|
284
|
+
hit_at_5,
|
|
285
|
+
hit_at_10,
|
|
286
|
+
mrr,
|
|
287
|
+
rank,
|
|
288
|
+
top_3_pages: resp.pages.slice(0, 3).map((p) => ({ id: p.id, title: p.title })),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function aggregate(results: QueryResult[]): Report {
|
|
293
|
+
const perStrategy: Record<Strategy, StrategyMetrics> = {
|
|
294
|
+
section: { count: 0, hit_at_1: 0, hit_at_5: 0, hit_at_10: 0, mrr: 0 },
|
|
295
|
+
property: { count: 0, hit_at_1: 0, hit_at_5: 0, hit_at_10: 0, mrr: 0 },
|
|
296
|
+
"cmd-path": { count: 0, hit_at_1: 0, hit_at_5: 0, hit_at_10: 0, mrr: 0 },
|
|
297
|
+
title: { count: 0, hit_at_1: 0, hit_at_5: 0, hit_at_10: 0, mrr: 0 },
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
for (const r of results) {
|
|
301
|
+
const s = perStrategy[r.source];
|
|
302
|
+
s.count += 1;
|
|
303
|
+
s.hit_at_1 += r.hit_at_1;
|
|
304
|
+
s.hit_at_5 += r.hit_at_5;
|
|
305
|
+
s.hit_at_10 += r.hit_at_10;
|
|
306
|
+
s.mrr += r.mrr;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
for (const s of Object.values(perStrategy)) {
|
|
310
|
+
if (s.count > 0) {
|
|
311
|
+
s.hit_at_1 /= s.count;
|
|
312
|
+
s.hit_at_5 /= s.count;
|
|
313
|
+
s.hit_at_10 /= s.count;
|
|
314
|
+
s.mrr /= s.count;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const total = results.length;
|
|
319
|
+
const overall = {
|
|
320
|
+
hit_at_1: total > 0 ? results.reduce((a, r) => a + r.hit_at_1, 0) / total : 0,
|
|
321
|
+
hit_at_5: total > 0 ? results.reduce((a, r) => a + r.hit_at_5, 0) / total : 0,
|
|
322
|
+
hit_at_10: total > 0 ? results.reduce((a, r) => a + r.hit_at_10, 0) / total : 0,
|
|
323
|
+
mrr: total > 0 ? results.reduce((a, r) => a + r.mrr, 0) / total : 0,
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
return {
|
|
327
|
+
generated_at: new Date().toISOString(),
|
|
328
|
+
total_queries: total,
|
|
329
|
+
per_strategy: perStrategy,
|
|
330
|
+
overall,
|
|
331
|
+
results,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ── Reporting ──────────────────────────────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
function fmtPct(x: number): string {
|
|
338
|
+
return `${(x * 100).toFixed(1)}%`;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function printReport(
|
|
342
|
+
report: Report,
|
|
343
|
+
baseline: Report | null,
|
|
344
|
+
topMisses: number,
|
|
345
|
+
): void {
|
|
346
|
+
console.log(
|
|
347
|
+
`\n📊 Rosetta self-supervised eval — ${report.total_queries} queries`,
|
|
348
|
+
);
|
|
349
|
+
console.log(` generated_at: ${report.generated_at}\n`);
|
|
350
|
+
|
|
351
|
+
console.log(" Overall metrics:");
|
|
352
|
+
const overallRows: { label: string; value: number; threshold?: number }[] = [
|
|
353
|
+
{ label: "Hit@1", value: report.overall.hit_at_1 },
|
|
354
|
+
{ label: "Hit@5", value: report.overall.hit_at_5 },
|
|
355
|
+
{ label: "Hit@10", value: report.overall.hit_at_10 },
|
|
356
|
+
{ label: "MRR", value: report.overall.mrr, threshold: THRESHOLDS.overall_mrr },
|
|
357
|
+
];
|
|
358
|
+
|
|
359
|
+
for (const row of overallRows) {
|
|
360
|
+
const ok =
|
|
361
|
+
row.threshold === undefined ? " " : row.value >= row.threshold ? "✅" : "❌";
|
|
362
|
+
const thresh = row.threshold === undefined ? "" : ` (≥ ${fmtPct(row.threshold)})`;
|
|
363
|
+
let delta = "";
|
|
364
|
+
if (baseline) {
|
|
365
|
+
const map: Record<string, number> = {
|
|
366
|
+
"Hit@1": baseline.overall.hit_at_1,
|
|
367
|
+
"Hit@5": baseline.overall.hit_at_5,
|
|
368
|
+
"Hit@10": baseline.overall.hit_at_10,
|
|
369
|
+
MRR: baseline.overall.mrr,
|
|
370
|
+
};
|
|
371
|
+
const b = map[row.label];
|
|
372
|
+
if (typeof b === "number") {
|
|
373
|
+
const d = row.value - b;
|
|
374
|
+
if (Math.abs(d) >= 0.001) {
|
|
375
|
+
delta = ` Δ ${d > 0 ? "+" : ""}${(d * 100).toFixed(1)}pp`;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
console.log(
|
|
380
|
+
` ${ok} ${row.label.padEnd(24)} ${fmtPct(row.value).padStart(7)}${thresh}${delta}`,
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
console.log("\n Per strategy:");
|
|
385
|
+
const strategies: Strategy[] = ["title", "section", "property", "cmd-path"];
|
|
386
|
+
for (const strategy of strategies) {
|
|
387
|
+
const s = report.per_strategy[strategy];
|
|
388
|
+
if (s.count === 0) continue;
|
|
389
|
+
|
|
390
|
+
let thresh = "";
|
|
391
|
+
let ok = " ";
|
|
392
|
+
if (strategy === "title") {
|
|
393
|
+
ok = s.hit_at_5 >= THRESHOLDS.title_hit_at_5 ? "✅" : "❌";
|
|
394
|
+
thresh = ` (hit@5 ≥ ${fmtPct(THRESHOLDS.title_hit_at_5)})`;
|
|
395
|
+
} else if (strategy === "section") {
|
|
396
|
+
ok = s.hit_at_10 >= THRESHOLDS.section_hit_at_10 ? "✅" : "❌";
|
|
397
|
+
thresh = ` (hit@10 ≥ ${fmtPct(THRESHOLDS.section_hit_at_10)})`;
|
|
398
|
+
} else if (strategy === "property") {
|
|
399
|
+
ok = s.hit_at_10 >= THRESHOLDS.property_hit_at_10 ? "✅" : "❌";
|
|
400
|
+
thresh = ` (hit@10 ≥ ${fmtPct(THRESHOLDS.property_hit_at_10)})`;
|
|
401
|
+
} else if (strategy === "cmd-path") {
|
|
402
|
+
ok = s.hit_at_5 >= THRESHOLDS.cmd_path_hit_at_5 ? "✅" : "❌";
|
|
403
|
+
thresh = ` (hit@5 ≥ ${fmtPct(THRESHOLDS.cmd_path_hit_at_5)})`;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
console.log(` ${ok} ${strategy.padEnd(12)} n=${String(s.count).padStart(3)} hit@1=${fmtPct(s.hit_at_1).padStart(7)} hit@5=${fmtPct(s.hit_at_5).padStart(7)} hit@10=${fmtPct(s.hit_at_10).padStart(7)} mrr=${fmtPct(s.mrr).padStart(7)}${thresh}`);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Top misses — highest-rank-but-still-missed queries
|
|
410
|
+
if (topMisses > 0) {
|
|
411
|
+
const misses = report.results
|
|
412
|
+
.filter((r) => r.rank === null || r.rank > 10)
|
|
413
|
+
.slice(0, topMisses);
|
|
414
|
+
|
|
415
|
+
if (misses.length > 0) {
|
|
416
|
+
console.log(`\n 🔍 Top ${topMisses} misses (expected page not in top 10):`);
|
|
417
|
+
for (const m of misses) {
|
|
418
|
+
console.log(` [${m.source}] "${m.query}" (expected page_id=${m.expected_page_id})`);
|
|
419
|
+
console.log(` top-3: ${m.top_3_pages.map((p) => `#${p.id} ${p.title}`).join(" | ")}`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ── Threshold + baseline gating ────────────────────────────────────────────
|
|
426
|
+
|
|
427
|
+
function checkThresholds(report: Report, filter?: Strategy): string[] {
|
|
428
|
+
const fails: string[] = [];
|
|
429
|
+
|
|
430
|
+
// Skip overall threshold checks when filtering by strategy
|
|
431
|
+
if (!filter) {
|
|
432
|
+
if (report.overall.mrr < THRESHOLDS.overall_mrr) {
|
|
433
|
+
fails.push(
|
|
434
|
+
`overall mrr ${fmtPct(report.overall.mrr)} < ${fmtPct(THRESHOLDS.overall_mrr)}`,
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Per-strategy thresholds
|
|
440
|
+
const title = report.per_strategy.title;
|
|
441
|
+
if (title.count > 0 && title.hit_at_5 < THRESHOLDS.title_hit_at_5) {
|
|
442
|
+
fails.push(
|
|
443
|
+
`title hit@5 ${fmtPct(title.hit_at_5)} < ${fmtPct(THRESHOLDS.title_hit_at_5)}`,
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const section = report.per_strategy.section;
|
|
448
|
+
if (section.count > 0 && section.hit_at_10 < THRESHOLDS.section_hit_at_10) {
|
|
449
|
+
fails.push(
|
|
450
|
+
`section hit@10 ${fmtPct(section.hit_at_10)} < ${fmtPct(THRESHOLDS.section_hit_at_10)}`,
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const property = report.per_strategy.property;
|
|
455
|
+
if (property.count > 0 && property.hit_at_10 < THRESHOLDS.property_hit_at_10) {
|
|
456
|
+
fails.push(
|
|
457
|
+
`property hit@10 ${fmtPct(property.hit_at_10)} < ${fmtPct(THRESHOLDS.property_hit_at_10)}`,
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const cmdPath = report.per_strategy["cmd-path"];
|
|
462
|
+
if (cmdPath.count > 0 && cmdPath.hit_at_5 < THRESHOLDS.cmd_path_hit_at_5) {
|
|
463
|
+
fails.push(
|
|
464
|
+
`cmd-path hit@5 ${fmtPct(cmdPath.hit_at_5)} < ${fmtPct(THRESHOLDS.cmd_path_hit_at_5)}`,
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return fails;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function checkRegression(
|
|
472
|
+
curr: Report,
|
|
473
|
+
base: Report,
|
|
474
|
+
filter?: Strategy,
|
|
475
|
+
tolerance = 0.05,
|
|
476
|
+
): string[] {
|
|
477
|
+
// Tolerance = 5pp; auto-gen queries are noisier than golden set.
|
|
478
|
+
const fails: string[] = [];
|
|
479
|
+
|
|
480
|
+
// Overall metrics
|
|
481
|
+
if (!filter) {
|
|
482
|
+
const keys: (keyof Report["overall"])[] = ["hit_at_1", "hit_at_5", "hit_at_10", "mrr"];
|
|
483
|
+
for (const k of keys) {
|
|
484
|
+
const d = curr.overall[k] - base.overall[k];
|
|
485
|
+
if (d < -tolerance) {
|
|
486
|
+
fails.push(
|
|
487
|
+
`overall ${k} regressed ${(d * 100).toFixed(1)}pp (was ${fmtPct(base.overall[k])}, now ${fmtPct(curr.overall[k])})`,
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Per-strategy hit@10 (most lenient metric)
|
|
494
|
+
const strategies: Strategy[] = filter
|
|
495
|
+
? [filter]
|
|
496
|
+
: ["title", "section", "property", "cmd-path"];
|
|
497
|
+
for (const strategy of strategies) {
|
|
498
|
+
const currS = curr.per_strategy[strategy];
|
|
499
|
+
const baseS = base.per_strategy[strategy];
|
|
500
|
+
if (currS.count > 0 && baseS.count > 0) {
|
|
501
|
+
const d = currS.hit_at_10 - baseS.hit_at_10;
|
|
502
|
+
if (d < -tolerance) {
|
|
503
|
+
fails.push(
|
|
504
|
+
`${strategy} hit@10 regressed ${(d * 100).toFixed(1)}pp (was ${fmtPct(baseS.hit_at_10)}, now ${fmtPct(currS.hit_at_10)})`,
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return fails;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// ── Main ───────────────────────────────────────────────────────────────────
|
|
514
|
+
|
|
515
|
+
const BASELINE_PATH = join(
|
|
516
|
+
import.meta.dir,
|
|
517
|
+
"../../fixtures/eval/self-supervised-baseline.json",
|
|
518
|
+
);
|
|
519
|
+
|
|
520
|
+
async function runEval(
|
|
521
|
+
filter?: Strategy,
|
|
522
|
+
limit?: number,
|
|
523
|
+
): Promise<Report> {
|
|
524
|
+
const queries = await generateQueries(filter, limit);
|
|
525
|
+
const results: QueryResult[] = [];
|
|
526
|
+
|
|
527
|
+
for (const q of queries) {
|
|
528
|
+
results.push(await evalQuery(q));
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
return aggregate(results);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (import.meta.main) {
|
|
535
|
+
const args = process.argv.slice(2);
|
|
536
|
+
const wantJson = args.includes("--json");
|
|
537
|
+
const wantUpdate = args.includes("--update-baseline");
|
|
538
|
+
const filterIdx = args.indexOf("--filter");
|
|
539
|
+
const filter = filterIdx >= 0 ? (args[filterIdx + 1] as Strategy) : undefined;
|
|
540
|
+
const limitIdx = args.indexOf("--limit");
|
|
541
|
+
const limit = limitIdx >= 0 ? Number.parseInt(args[limitIdx + 1] as string, 10) : undefined;
|
|
542
|
+
const topMissesIdx = args.indexOf("--top-misses");
|
|
543
|
+
const topMisses =
|
|
544
|
+
topMissesIdx >= 0 ? Number.parseInt(args[topMissesIdx + 1] as string, 10) : 10;
|
|
545
|
+
|
|
546
|
+
try {
|
|
547
|
+
const report = await runEval(filter, limit);
|
|
548
|
+
|
|
549
|
+
if (wantJson) {
|
|
550
|
+
console.log(JSON.stringify(report, null, 2));
|
|
551
|
+
process.exit(0);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const baseline: Report | null = existsSync(BASELINE_PATH)
|
|
555
|
+
? (JSON.parse(readFileSync(BASELINE_PATH, "utf-8")) as Report)
|
|
556
|
+
: null;
|
|
557
|
+
|
|
558
|
+
printReport(report, baseline, topMisses);
|
|
559
|
+
|
|
560
|
+
if (wantUpdate) {
|
|
561
|
+
writeFileSync(BASELINE_PATH, `${JSON.stringify(report, null, 2)}\n`);
|
|
562
|
+
console.log(`\n 💾 baseline updated → ${BASELINE_PATH}`);
|
|
563
|
+
process.exit(0);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Gate: thresholds + regression
|
|
567
|
+
const thresholdFails = checkThresholds(report, filter);
|
|
568
|
+
const regressionFails =
|
|
569
|
+
baseline ? checkRegression(report, baseline, filter) : [];
|
|
570
|
+
|
|
571
|
+
if (thresholdFails.length > 0 || regressionFails.length > 0) {
|
|
572
|
+
console.log("\n ❌ FAIL");
|
|
573
|
+
for (const f of thresholdFails) console.log(` threshold: ${f}`);
|
|
574
|
+
for (const f of regressionFails) console.log(` regression: ${f}`);
|
|
575
|
+
console.log("\n Run with --update-baseline if this is intentional.\n");
|
|
576
|
+
process.exit(1);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
console.log("\n ✅ all checks passed\n");
|
|
580
|
+
process.exit(0);
|
|
581
|
+
} catch (err) {
|
|
582
|
+
console.error(`[eval] runner error: ${err}`);
|
|
583
|
+
process.exit(2);
|
|
584
|
+
}
|
|
585
|
+
}
|