@tikoci/rosetta 0.8.7 → 0.8.9
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/eval/retrieval.ts +469 -0
- package/src/eval/self-supervised.ts +585 -0
- package/src/mcp-contract.test.ts +263 -0
- package/src/release.test.ts +11 -0
package/package.json
CHANGED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 0 — Golden-query retrieval evaluation runner.
|
|
3
|
+
*
|
|
4
|
+
* Loads fixtures/eval/queries.json, calls searchAll() for each query, and
|
|
5
|
+
* computes classical IR metrics (recall@k, MRR) plus classifier-detection
|
|
6
|
+
* accuracy. No LLM call anywhere — this is fully deterministic and runs
|
|
7
|
+
* against the committed ros-help.db in seconds.
|
|
8
|
+
*
|
|
9
|
+
* Modes:
|
|
10
|
+
* bun run src/eval/retrieval.ts → run + print report + compare to baseline
|
|
11
|
+
* bun run src/eval/retrieval.ts --json → run + emit JSON report on stdout
|
|
12
|
+
* bun run src/eval/retrieval.ts --update-baseline
|
|
13
|
+
* → run + overwrite fixtures/eval/baseline.json
|
|
14
|
+
* bun run src/eval/retrieval.ts --filter <id-prefix>
|
|
15
|
+
* → only run queries whose id starts with prefix
|
|
16
|
+
*
|
|
17
|
+
* Exit codes:
|
|
18
|
+
* 0 all metrics meet thresholds AND no regression vs baseline
|
|
19
|
+
* 1 threshold or baseline regression
|
|
20
|
+
* 2 runner error (bad fixture, DB missing, etc.)
|
|
21
|
+
*
|
|
22
|
+
* See BACKLOG.md "MCP Behavioral Testing — Phase 0" for design rationale.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
import { searchAll } from "../query.ts";
|
|
28
|
+
|
|
29
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
type Shape =
|
|
32
|
+
| "nl-question"
|
|
33
|
+
| "command-path"
|
|
34
|
+
| "version-question"
|
|
35
|
+
| "device"
|
|
36
|
+
| "topic-multi"
|
|
37
|
+
| "ambiguous";
|
|
38
|
+
|
|
39
|
+
type ExpectedClassified = {
|
|
40
|
+
command_path?: string;
|
|
41
|
+
version?: string;
|
|
42
|
+
device?: string;
|
|
43
|
+
property?: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
type MatchMode = "any" | "all";
|
|
47
|
+
|
|
48
|
+
type GoldenQuery = {
|
|
49
|
+
id: string;
|
|
50
|
+
query: string;
|
|
51
|
+
shape: Shape;
|
|
52
|
+
expected_pages?: number[];
|
|
53
|
+
/** "any" (default): top-k contains ≥1 expected page → recall=1. "all": classical subset recall. */
|
|
54
|
+
match_mode?: MatchMode;
|
|
55
|
+
expected_classified?: ExpectedClassified;
|
|
56
|
+
expected_topics_any?: string[];
|
|
57
|
+
expected_related?: string[];
|
|
58
|
+
/** Skip this entire query's checks unless DB has at least this many commands. Lets us keep
|
|
59
|
+
* command-tree-dependent checks in the golden set without false-failing on slim dev DBs. */
|
|
60
|
+
requires_commands_min?: number;
|
|
61
|
+
notes?: string;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
type Thresholds = {
|
|
65
|
+
recall_at_5: number;
|
|
66
|
+
recall_at_3: number;
|
|
67
|
+
mrr: number;
|
|
68
|
+
classifier_accuracy: number;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
type GoldenSet = {
|
|
72
|
+
_thresholds: Thresholds;
|
|
73
|
+
queries: GoldenQuery[];
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
type QueryResult = {
|
|
77
|
+
id: string;
|
|
78
|
+
query: string;
|
|
79
|
+
shape: Shape;
|
|
80
|
+
recall_at_5: number;
|
|
81
|
+
recall_at_3: number;
|
|
82
|
+
reciprocal_rank: number;
|
|
83
|
+
classifier_ok: boolean;
|
|
84
|
+
related_ok: boolean;
|
|
85
|
+
topics_ok: boolean;
|
|
86
|
+
skipped: boolean;
|
|
87
|
+
skip_reason?: string;
|
|
88
|
+
top_pages: { id: number; title: string }[];
|
|
89
|
+
classified_actual: Record<string, unknown>;
|
|
90
|
+
notes: string[];
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
type Report = {
|
|
94
|
+
generated_at: string;
|
|
95
|
+
total_queries: number;
|
|
96
|
+
metrics: {
|
|
97
|
+
recall_at_5: number;
|
|
98
|
+
recall_at_3: number;
|
|
99
|
+
mrr: number;
|
|
100
|
+
classifier_accuracy: number;
|
|
101
|
+
related_block_accuracy: number;
|
|
102
|
+
topics_accuracy: number;
|
|
103
|
+
};
|
|
104
|
+
per_shape: Record<string, { count: number; recall_at_5: number; mrr: number }>;
|
|
105
|
+
results: QueryResult[];
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// ── Loaders ────────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
const FIXTURE_PATH = join(import.meta.dir, "../../fixtures/eval/queries.json");
|
|
111
|
+
const BASELINE_PATH = join(import.meta.dir, "../../fixtures/eval/baseline.json");
|
|
112
|
+
|
|
113
|
+
function loadGoldenSet(): GoldenSet {
|
|
114
|
+
if (!existsSync(FIXTURE_PATH)) {
|
|
115
|
+
console.error(`[eval] golden set not found at ${FIXTURE_PATH}`);
|
|
116
|
+
process.exit(2);
|
|
117
|
+
}
|
|
118
|
+
const raw = readFileSync(FIXTURE_PATH, "utf-8");
|
|
119
|
+
const parsed = JSON.parse(raw) as GoldenSet & { _doc?: string };
|
|
120
|
+
if (!parsed.queries || !Array.isArray(parsed.queries)) {
|
|
121
|
+
console.error("[eval] fixture missing 'queries' array");
|
|
122
|
+
process.exit(2);
|
|
123
|
+
}
|
|
124
|
+
return parsed;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── Per-query evaluation ───────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
function evalQuery(q: GoldenQuery, commandsCount: number, k = 5): QueryResult {
|
|
130
|
+
const empty: QueryResult = {
|
|
131
|
+
id: q.id,
|
|
132
|
+
query: q.query,
|
|
133
|
+
shape: q.shape,
|
|
134
|
+
recall_at_5: 1,
|
|
135
|
+
recall_at_3: 1,
|
|
136
|
+
reciprocal_rank: 1,
|
|
137
|
+
classifier_ok: true,
|
|
138
|
+
related_ok: true,
|
|
139
|
+
topics_ok: true,
|
|
140
|
+
skipped: false,
|
|
141
|
+
top_pages: [],
|
|
142
|
+
classified_actual: {},
|
|
143
|
+
notes: [],
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
if (q.requires_commands_min && commandsCount < q.requires_commands_min) {
|
|
147
|
+
return {
|
|
148
|
+
...empty,
|
|
149
|
+
skipped: true,
|
|
150
|
+
skip_reason: `requires commands ≥ ${q.requires_commands_min}, DB has ${commandsCount}`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const resp = searchAll(q.query, k * 2);
|
|
155
|
+
const topIds = resp.pages.slice(0, k).map((p) => p.id);
|
|
156
|
+
const top3Ids = resp.pages.slice(0, 3).map((p) => p.id);
|
|
157
|
+
|
|
158
|
+
// Recall semantics — default "any" (≥1 expected page in top-k counts as full recall).
|
|
159
|
+
// For QA-style retrieval we usually only need ONE good answer; classical subset recall
|
|
160
|
+
// ("all" mode) is opt-in for cases where coverage actually matters.
|
|
161
|
+
const expected = q.expected_pages ?? [];
|
|
162
|
+
const mode: MatchMode = q.match_mode ?? "any";
|
|
163
|
+
const recallFor = (ids: number[]): number => {
|
|
164
|
+
if (expected.length === 0) return 1;
|
|
165
|
+
if (mode === "any") {
|
|
166
|
+
return expected.some((id) => ids.includes(id)) ? 1 : 0;
|
|
167
|
+
}
|
|
168
|
+
// "all" mode: classical subset recall
|
|
169
|
+
return expected.filter((id) => ids.includes(id)).length / expected.length;
|
|
170
|
+
};
|
|
171
|
+
const recall_at_5 = recallFor(topIds);
|
|
172
|
+
const recall_at_3 = recallFor(top3Ids);
|
|
173
|
+
|
|
174
|
+
// Reciprocal rank: 1/rank of first expected page in top-k. 0 if none found.
|
|
175
|
+
let rr = 0;
|
|
176
|
+
if (expected.length > 0) {
|
|
177
|
+
for (let i = 0; i < topIds.length; i++) {
|
|
178
|
+
if (expected.includes(topIds[i] as number)) {
|
|
179
|
+
rr = 1 / (i + 1);
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
} else {
|
|
184
|
+
rr = 1; // N/A — don't penalize MRR
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Classifier check: every key in expected_classified must match exactly.
|
|
188
|
+
const notes: string[] = [];
|
|
189
|
+
let classifier_ok = true;
|
|
190
|
+
if (q.expected_classified) {
|
|
191
|
+
for (const [key, want] of Object.entries(q.expected_classified)) {
|
|
192
|
+
const got = (resp.classified as Record<string, unknown>)[key];
|
|
193
|
+
if (got !== want) {
|
|
194
|
+
classifier_ok = false;
|
|
195
|
+
notes.push(`classifier.${key}: want=${JSON.stringify(want)} got=${JSON.stringify(got)}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Related-block check: each name in expected_related must appear in resp.related.
|
|
201
|
+
let related_ok = true;
|
|
202
|
+
if (q.expected_related && q.expected_related.length > 0) {
|
|
203
|
+
for (const key of q.expected_related) {
|
|
204
|
+
if (!(key in resp.related) || resp.related[key as keyof typeof resp.related] == null) {
|
|
205
|
+
related_ok = false;
|
|
206
|
+
notes.push(`related.${key}: missing`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Topics check: at least one expected topic must appear in classified.topics.
|
|
212
|
+
let topics_ok = true;
|
|
213
|
+
if (q.expected_topics_any && q.expected_topics_any.length > 0) {
|
|
214
|
+
const got = resp.classified.topics ?? [];
|
|
215
|
+
const hit = q.expected_topics_any.some((t) => got.includes(t));
|
|
216
|
+
if (!hit) {
|
|
217
|
+
topics_ok = false;
|
|
218
|
+
notes.push(`topics: want any of ${JSON.stringify(q.expected_topics_any)} got=${JSON.stringify(got)}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (expected.length > 0 && recall_at_5 === 0) {
|
|
223
|
+
notes.push(`top-${k} pages: ${topIds.join(", ")} (none of expected ${expected.join(", ")})`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
id: q.id,
|
|
228
|
+
query: q.query,
|
|
229
|
+
shape: q.shape,
|
|
230
|
+
recall_at_5,
|
|
231
|
+
recall_at_3,
|
|
232
|
+
reciprocal_rank: rr,
|
|
233
|
+
classifier_ok,
|
|
234
|
+
related_ok,
|
|
235
|
+
topics_ok,
|
|
236
|
+
skipped: false,
|
|
237
|
+
top_pages: resp.pages.slice(0, 5).map((p) => ({ id: p.id, title: p.title })),
|
|
238
|
+
classified_actual: { ...resp.classified },
|
|
239
|
+
notes,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ── Aggregation ────────────────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
function aggregate(results: QueryResult[]): Report["metrics"] {
|
|
246
|
+
const active = results.filter((r) => !r.skipped);
|
|
247
|
+
const n = active.length;
|
|
248
|
+
if (n === 0) {
|
|
249
|
+
return {
|
|
250
|
+
recall_at_5: 0,
|
|
251
|
+
recall_at_3: 0,
|
|
252
|
+
mrr: 0,
|
|
253
|
+
classifier_accuracy: 0,
|
|
254
|
+
related_block_accuracy: 0,
|
|
255
|
+
topics_accuracy: 0,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
const sum = (f: (r: QueryResult) => number) => active.reduce((a, r) => a + f(r), 0);
|
|
259
|
+
return {
|
|
260
|
+
recall_at_5: sum((r) => r.recall_at_5) / n,
|
|
261
|
+
recall_at_3: sum((r) => r.recall_at_3) / n,
|
|
262
|
+
mrr: sum((r) => r.reciprocal_rank) / n,
|
|
263
|
+
classifier_accuracy: sum((r) => (r.classifier_ok ? 1 : 0)) / n,
|
|
264
|
+
related_block_accuracy: sum((r) => (r.related_ok ? 1 : 0)) / n,
|
|
265
|
+
topics_accuracy: sum((r) => (r.topics_ok ? 1 : 0)) / n,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function perShape(results: QueryResult[]): Report["per_shape"] {
|
|
270
|
+
const out: Report["per_shape"] = {};
|
|
271
|
+
for (const r of results) {
|
|
272
|
+
if (r.skipped) continue;
|
|
273
|
+
if (!out[r.shape]) {
|
|
274
|
+
out[r.shape] = { count: 0, recall_at_5: 0, mrr: 0 };
|
|
275
|
+
}
|
|
276
|
+
const bucket = out[r.shape];
|
|
277
|
+
bucket.count += 1;
|
|
278
|
+
bucket.recall_at_5 += r.recall_at_5;
|
|
279
|
+
bucket.mrr += r.reciprocal_rank;
|
|
280
|
+
}
|
|
281
|
+
for (const k of Object.keys(out)) {
|
|
282
|
+
const b = out[k];
|
|
283
|
+
if (!b) continue;
|
|
284
|
+
b.recall_at_5 = b.recall_at_5 / b.count;
|
|
285
|
+
b.mrr = b.mrr / b.count;
|
|
286
|
+
}
|
|
287
|
+
return out;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ── Reporting ──────────────────────────────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
function fmtPct(x: number): string {
|
|
293
|
+
return `${(x * 100).toFixed(1)}%`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function printReport(report: Report, thresholds: Thresholds, baseline: Report | null): void {
|
|
297
|
+
const m = report.metrics;
|
|
298
|
+
console.log(`\n📊 Rosetta retrieval eval — ${report.total_queries} queries`);
|
|
299
|
+
console.log(` generated_at: ${report.generated_at}\n`);
|
|
300
|
+
|
|
301
|
+
const rows: { label: string; value: number; threshold?: number }[] = [
|
|
302
|
+
{ label: "Recall@5", value: m.recall_at_5, threshold: thresholds.recall_at_5 },
|
|
303
|
+
{ label: "Recall@3", value: m.recall_at_3, threshold: thresholds.recall_at_3 },
|
|
304
|
+
{ label: "MRR", value: m.mrr, threshold: thresholds.mrr },
|
|
305
|
+
{
|
|
306
|
+
label: "Classifier accuracy",
|
|
307
|
+
value: m.classifier_accuracy,
|
|
308
|
+
threshold: thresholds.classifier_accuracy,
|
|
309
|
+
},
|
|
310
|
+
{ label: "Related-block accuracy", value: m.related_block_accuracy },
|
|
311
|
+
{ label: "Topics accuracy", value: m.topics_accuracy },
|
|
312
|
+
];
|
|
313
|
+
|
|
314
|
+
for (const row of rows) {
|
|
315
|
+
const ok =
|
|
316
|
+
row.threshold === undefined ? " " : row.value >= row.threshold ? "✅" : "❌";
|
|
317
|
+
const thresh = row.threshold === undefined ? "" : ` (≥ ${fmtPct(row.threshold)})`;
|
|
318
|
+
let delta = "";
|
|
319
|
+
if (baseline) {
|
|
320
|
+
const prev = (baseline.metrics as Record<string, number>)[
|
|
321
|
+
row.label.toLowerCase().replace(/[^a-z0-9]+/g, "_")
|
|
322
|
+
];
|
|
323
|
+
// Map labels to baseline keys
|
|
324
|
+
const map: Record<string, number> = {
|
|
325
|
+
"Recall@5": baseline.metrics.recall_at_5,
|
|
326
|
+
"Recall@3": baseline.metrics.recall_at_3,
|
|
327
|
+
MRR: baseline.metrics.mrr,
|
|
328
|
+
"Classifier accuracy": baseline.metrics.classifier_accuracy,
|
|
329
|
+
"Related-block accuracy": baseline.metrics.related_block_accuracy,
|
|
330
|
+
"Topics accuracy": baseline.metrics.topics_accuracy,
|
|
331
|
+
};
|
|
332
|
+
const b = map[row.label] ?? prev;
|
|
333
|
+
if (typeof b === "number") {
|
|
334
|
+
const d = row.value - b;
|
|
335
|
+
if (Math.abs(d) >= 0.001) {
|
|
336
|
+
delta = ` Δ ${d > 0 ? "+" : ""}${(d * 100).toFixed(1)}pp`;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
console.log(` ${ok} ${row.label.padEnd(24)} ${fmtPct(row.value).padStart(7)}${thresh}${delta}`);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
console.log("\n Per shape:");
|
|
344
|
+
for (const [shape, b] of Object.entries(report.per_shape)) {
|
|
345
|
+
console.log(
|
|
346
|
+
` ${shape.padEnd(20)} n=${String(b.count).padStart(2)} recall@5=${fmtPct(b.recall_at_5).padStart(7)} mrr=${fmtPct(b.mrr).padStart(7)}`,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Skipped + failure detail
|
|
351
|
+
const skipped = report.results.filter((r) => r.skipped);
|
|
352
|
+
if (skipped.length > 0) {
|
|
353
|
+
console.log(`\n ⏭ ${skipped.length} queries skipped (env doesn't support):`);
|
|
354
|
+
for (const s of skipped) console.log(` [${s.id}] ${s.skip_reason}`);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const failures = report.results.filter(
|
|
358
|
+
(r) =>
|
|
359
|
+
!r.skipped &&
|
|
360
|
+
(r.recall_at_5 < 1 || !r.classifier_ok || !r.related_ok || !r.topics_ok),
|
|
361
|
+
);
|
|
362
|
+
if (failures.length > 0) {
|
|
363
|
+
console.log(`\n ⚠️ ${failures.length} queries with issues:`);
|
|
364
|
+
for (const f of failures) {
|
|
365
|
+
console.log(` [${f.id}] "${f.query}"`);
|
|
366
|
+
for (const note of f.notes) console.log(` ${note}`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ── Threshold + baseline gating ────────────────────────────────────────────
|
|
372
|
+
|
|
373
|
+
function checkThresholds(metrics: Report["metrics"], t: Thresholds): string[] {
|
|
374
|
+
const fails: string[] = [];
|
|
375
|
+
if (metrics.recall_at_5 < t.recall_at_5)
|
|
376
|
+
fails.push(`recall@5 ${fmtPct(metrics.recall_at_5)} < ${fmtPct(t.recall_at_5)}`);
|
|
377
|
+
if (metrics.recall_at_3 < t.recall_at_3)
|
|
378
|
+
fails.push(`recall@3 ${fmtPct(metrics.recall_at_3)} < ${fmtPct(t.recall_at_3)}`);
|
|
379
|
+
if (metrics.mrr < t.mrr) fails.push(`mrr ${fmtPct(metrics.mrr)} < ${fmtPct(t.mrr)}`);
|
|
380
|
+
if (metrics.classifier_accuracy < t.classifier_accuracy)
|
|
381
|
+
fails.push(
|
|
382
|
+
`classifier ${fmtPct(metrics.classifier_accuracy)} < ${fmtPct(t.classifier_accuracy)}`,
|
|
383
|
+
);
|
|
384
|
+
return fails;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function checkRegression(curr: Report, base: Report, tolerance = 0.02): string[] {
|
|
388
|
+
// Tolerance = 2pp; FTS5 BM25 tweaks shouldn't trigger on noise.
|
|
389
|
+
const fails: string[] = [];
|
|
390
|
+
const keys: (keyof Report["metrics"])[] = [
|
|
391
|
+
"recall_at_5",
|
|
392
|
+
"recall_at_3",
|
|
393
|
+
"mrr",
|
|
394
|
+
"classifier_accuracy",
|
|
395
|
+
];
|
|
396
|
+
for (const k of keys) {
|
|
397
|
+
const d = curr.metrics[k] - base.metrics[k];
|
|
398
|
+
if (d < -tolerance) {
|
|
399
|
+
fails.push(`${k} regressed ${(d * 100).toFixed(1)}pp (was ${fmtPct(base.metrics[k])}, now ${fmtPct(curr.metrics[k])})`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return fails;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ── Main ───────────────────────────────────────────────────────────────────
|
|
406
|
+
|
|
407
|
+
export function runEval(filterPrefix?: string): Report {
|
|
408
|
+
// Lazy import db to avoid pulling it at module-load (so test isolation rules
|
|
409
|
+
// around DB_PATH still work — see extraction.instructions.md).
|
|
410
|
+
const { db } = require("../db.ts") as typeof import("../db.ts");
|
|
411
|
+
const commandsCount = (db.prepare("SELECT COUNT(*) as c FROM commands").get() as { c: number }).c;
|
|
412
|
+
|
|
413
|
+
const set = loadGoldenSet();
|
|
414
|
+
const queries = filterPrefix
|
|
415
|
+
? set.queries.filter((q) => q.id.startsWith(filterPrefix))
|
|
416
|
+
: set.queries;
|
|
417
|
+
|
|
418
|
+
const results = queries.map((q) => evalQuery(q, commandsCount));
|
|
419
|
+
return {
|
|
420
|
+
generated_at: new Date().toISOString(),
|
|
421
|
+
total_queries: results.length,
|
|
422
|
+
metrics: aggregate(results),
|
|
423
|
+
per_shape: perShape(results),
|
|
424
|
+
results,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (import.meta.main) {
|
|
429
|
+
const args = process.argv.slice(2);
|
|
430
|
+
const wantJson = args.includes("--json");
|
|
431
|
+
const wantUpdate = args.includes("--update-baseline");
|
|
432
|
+
const filterIdx = args.indexOf("--filter");
|
|
433
|
+
const filter = filterIdx >= 0 ? args[filterIdx + 1] : undefined;
|
|
434
|
+
|
|
435
|
+
const set = loadGoldenSet();
|
|
436
|
+
const report = runEval(filter);
|
|
437
|
+
|
|
438
|
+
if (wantJson) {
|
|
439
|
+
console.log(JSON.stringify(report, null, 2));
|
|
440
|
+
process.exit(0);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const baseline: Report | null = existsSync(BASELINE_PATH)
|
|
444
|
+
? (JSON.parse(readFileSync(BASELINE_PATH, "utf-8")) as Report)
|
|
445
|
+
: null;
|
|
446
|
+
|
|
447
|
+
printReport(report, set._thresholds, baseline);
|
|
448
|
+
|
|
449
|
+
if (wantUpdate) {
|
|
450
|
+
writeFileSync(BASELINE_PATH, `${JSON.stringify(report, null, 2)}\n`);
|
|
451
|
+
console.log(`\n 💾 baseline updated → ${BASELINE_PATH}`);
|
|
452
|
+
process.exit(0);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Gate: thresholds + regression
|
|
456
|
+
const thresholdFails = filter ? [] : checkThresholds(report.metrics, set._thresholds);
|
|
457
|
+
const regressionFails = baseline && !filter ? checkRegression(report, baseline) : [];
|
|
458
|
+
|
|
459
|
+
if (thresholdFails.length > 0 || regressionFails.length > 0) {
|
|
460
|
+
console.log("\n ❌ FAIL");
|
|
461
|
+
for (const f of thresholdFails) console.log(` threshold: ${f}`);
|
|
462
|
+
for (const f of regressionFails) console.log(` regression: ${f}`);
|
|
463
|
+
console.log("\n Run with --update-baseline if this is intentional.\n");
|
|
464
|
+
process.exit(1);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
console.log("\n ✅ all checks passed\n");
|
|
468
|
+
process.exit(0);
|
|
469
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp-contract.test.ts — MCP tool surface contract tests (Phase 2).
|
|
3
|
+
*
|
|
4
|
+
* Guards against silent breaking changes to the MCP tool registry:
|
|
5
|
+
* - Block A: Frozen 13-tool list + workflow-arrow (→) convention in descriptions
|
|
6
|
+
* - Block B: Token-budget guardrails for 10 canonical queries (rough chars/4)
|
|
7
|
+
* - Block C: Shape snapshots — fingerprint the *contract* (keys, counts,
|
|
8
|
+
* classifier output), NOT the corpus. Deliberately omits page IDs
|
|
9
|
+
* and titles so DB refreshes don't churn snapshots.
|
|
10
|
+
*
|
|
11
|
+
* These are fast, deterministic, CI-runnable structural tests. No LLM calls,
|
|
12
|
+
* no network. Use the real local DB (ros-help.db) for stable FTS results.
|
|
13
|
+
*
|
|
14
|
+
* When adding/removing/renaming a tool: update EXPECTED_TOOLS below AND add
|
|
15
|
+
* a CHANGELOG entry under [Unreleased] → Added/Changed/Removed. The test is
|
|
16
|
+
* designed to force an explicit decision.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, expect, test } from "bun:test";
|
|
19
|
+
import { readFileSync } from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
|
|
22
|
+
const ROOT = path.resolve(import.meta.dirname, "..");
|
|
23
|
+
|
|
24
|
+
// Block A (static file parse) runs unconditionally. Blocks B and C need the
|
|
25
|
+
// real populated DB singleton. When another test file (extract-videos,
|
|
26
|
+
// query, etc.) has already pinned the db.ts singleton to :memory:, we skip
|
|
27
|
+
// the DB-dependent blocks — running `bun test src/mcp-contract.test.ts`
|
|
28
|
+
// alone exercises them, and CI's `bun test` still gets Block A coverage.
|
|
29
|
+
const { searchAll } = await import("./query.ts");
|
|
30
|
+
const { DB_PATH, getDbStats } = await import("./db.ts");
|
|
31
|
+
|
|
32
|
+
// getDbStats() throws if tables don't exist (clean checkout before any DB build).
|
|
33
|
+
// Guard defensively: any failure → treat as "DB not usable" and skip B/C.
|
|
34
|
+
function dbPagesOrZero(): number {
|
|
35
|
+
try {
|
|
36
|
+
return getDbStats().pages;
|
|
37
|
+
} catch {
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const dbPages = dbPagesOrZero();
|
|
43
|
+
const dbIsReal = DB_PATH !== ":memory:" && dbPages > 100;
|
|
44
|
+
const skipReason = dbIsReal
|
|
45
|
+
? ""
|
|
46
|
+
: `DB singleton is "${DB_PATH}" (pages=${dbPages}); run \`bun test src/mcp-contract.test.ts\` solo against a populated DB for Blocks B/C.`;
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Block A: Frozen tool registry
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
describe("Frozen tool registry", () => {
|
|
53
|
+
const EXPECTED_TOOLS = [
|
|
54
|
+
"routeros_search",
|
|
55
|
+
"routeros_get_page",
|
|
56
|
+
"routeros_lookup_property",
|
|
57
|
+
"routeros_command_tree",
|
|
58
|
+
"routeros_stats",
|
|
59
|
+
"routeros_search_changelogs",
|
|
60
|
+
"routeros_dude_search",
|
|
61
|
+
"routeros_dude_get_page",
|
|
62
|
+
"routeros_command_version_check",
|
|
63
|
+
"routeros_command_diff",
|
|
64
|
+
"routeros_device_lookup",
|
|
65
|
+
"routeros_search_tests",
|
|
66
|
+
"routeros_current_versions",
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
test("exactly 13 tools registered", () => {
|
|
70
|
+
const mcpSrc = readFileSync(path.join(ROOT, "src/mcp.ts"), "utf-8");
|
|
71
|
+
// Extract tool names from server.registerTool("<name>", patterns
|
|
72
|
+
const toolMatches = mcpSrc.matchAll(/server\.registerTool\(\s*["']([^"']+)["']/g);
|
|
73
|
+
const foundTools = Array.from(toolMatches, (m) => m[1]);
|
|
74
|
+
|
|
75
|
+
expect(foundTools.length).toBe(13);
|
|
76
|
+
expect(foundTools.sort()).toEqual(EXPECTED_TOOLS.sort());
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("all tools have workflow arrow (→) in description", () => {
|
|
80
|
+
const mcpSrc = readFileSync(path.join(ROOT, "src/mcp.ts"), "utf-8");
|
|
81
|
+
|
|
82
|
+
// Terminal/informational tools that don't have natural follow-ups
|
|
83
|
+
// (identified during Phase 2 implementation as missing workflow arrows)
|
|
84
|
+
const KNOWN_EXCEPTIONS = ["routeros_stats", "routeros_current_versions"];
|
|
85
|
+
|
|
86
|
+
// Extract each complete registerTool block (tool name to closing paren before next registerTool)
|
|
87
|
+
// Split on registerTool calls, then extract name + description from each block
|
|
88
|
+
const toolBlocks = mcpSrc.split(/(?=server\.registerTool\()/);
|
|
89
|
+
|
|
90
|
+
const toolsWithoutArrow: string[] = [];
|
|
91
|
+
|
|
92
|
+
for (const block of toolBlocks) {
|
|
93
|
+
// Extract tool name
|
|
94
|
+
const nameMatch = block.match(/server\.registerTool\(\s*["']([^"']+)["']/);
|
|
95
|
+
if (!nameMatch) continue;
|
|
96
|
+
|
|
97
|
+
const toolName = nameMatch[1];
|
|
98
|
+
|
|
99
|
+
// Extract description (from `description:` to the closing `inputSchema:`)
|
|
100
|
+
// This captures the full description including embedded backticks
|
|
101
|
+
const descMatch = block.match(/description:\s*`([\s\S]*?)`\s*,\s*inputSchema:/);
|
|
102
|
+
if (!descMatch) {
|
|
103
|
+
// Some tools might not have inputSchema, try alternate pattern
|
|
104
|
+
const altMatch = block.match(/description:\s*`([\s\S]*?)`\s*,?\s*\}/);
|
|
105
|
+
if (!altMatch) continue;
|
|
106
|
+
|
|
107
|
+
const description = altMatch[1];
|
|
108
|
+
if (!description.includes("→") && !KNOWN_EXCEPTIONS.includes(toolName)) {
|
|
109
|
+
toolsWithoutArrow.push(toolName);
|
|
110
|
+
}
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const description = descMatch[1];
|
|
115
|
+
if (!description.includes("→") && !KNOWN_EXCEPTIONS.includes(toolName)) {
|
|
116
|
+
toolsWithoutArrow.push(toolName);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (toolsWithoutArrow.length > 0) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`Tools lacking workflow arrow (→) convention: ${toolsWithoutArrow.join(", ")}`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Ensure we found tool descriptions (sanity check for regex)
|
|
127
|
+
const totalFound = Array.from(
|
|
128
|
+
mcpSrc.matchAll(/server\.registerTool\(\s*["']([^"']+)["']/g),
|
|
129
|
+
).length;
|
|
130
|
+
expect(totalFound).toBe(13);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Block B: Token-budget guardrails
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
describe.skipIf(!dbIsReal)(`Token-budget guardrails${dbIsReal ? "" : ` [skipped: ${skipReason}]`}`, () => {
|
|
139
|
+
/**
|
|
140
|
+
* Rough token estimator: 1 token ≈ 4 chars for JSON.
|
|
141
|
+
* This is a guardrail to catch 10x regressions, not precise billing.
|
|
142
|
+
*/
|
|
143
|
+
function estimateTokens(obj: unknown): number {
|
|
144
|
+
return Math.ceil(JSON.stringify(obj).length / 4);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const QUERIES: Array<{ query: string; limit: number; budget: number }> = [
|
|
148
|
+
{ query: "dhcp server", limit: 8, budget: 8000 },
|
|
149
|
+
{ query: "/ip/firewall/filter", limit: 8, budget: 8000 },
|
|
150
|
+
{ query: "bridge vlan", limit: 8, budget: 8000 },
|
|
151
|
+
{ query: "hAP ax3", limit: 8, budget: 6000 },
|
|
152
|
+
{ query: "what changed in 7.22.1", limit: 8, budget: 8000 },
|
|
153
|
+
{ query: "BGP", limit: 8, budget: 8000 },
|
|
154
|
+
{ query: "container setup", limit: 8, budget: 8000 },
|
|
155
|
+
{ query: "disabled property", limit: 8, budget: 6000 },
|
|
156
|
+
{ query: "CAPsMAN", limit: 20, budget: 16000 }, // hunger knob test
|
|
157
|
+
{ query: "firewall filter chain", limit: 8, budget: 8000 },
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
for (const { query, limit, budget } of QUERIES) {
|
|
161
|
+
test(`"${query}" (limit=${limit}) ≤ ${budget} tokens`, () => {
|
|
162
|
+
const result = searchAll(query, limit);
|
|
163
|
+
const tokens = estimateTokens(result);
|
|
164
|
+
|
|
165
|
+
if (tokens > budget) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`Token budget exceeded: "${query}" | actual=${tokens} tokens | budget=${budget}`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Log for the record (visible on test run)
|
|
172
|
+
console.log(` ✓ "${query}" (limit=${limit}): ${tokens} tokens`);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// Block C: Response-shape invariants
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
//
|
|
181
|
+
// This block asserts shape contracts that hold on any populated DB — no
|
|
182
|
+
// file-based snapshots (they coupled to the local dev DB's extraction state
|
|
183
|
+
// and drifted against the full CI-built DB's richer `related_buckets`). The
|
|
184
|
+
// invariants here protect against silent breakage of the searchAll return
|
|
185
|
+
// shape while staying portable across DBs of varying richness.
|
|
186
|
+
//
|
|
187
|
+
// Corpus-linked expectations ("this page must rank for this query") live in
|
|
188
|
+
// fixtures/eval/queries.json (Phase 0) — that's the right surface for them.
|
|
189
|
+
|
|
190
|
+
describe.skipIf(!dbIsReal)(`Response-shape invariants${dbIsReal ? "" : ` [skipped: ${skipReason}]`}`, () => {
|
|
191
|
+
type Invariant = {
|
|
192
|
+
query: string;
|
|
193
|
+
limit: number;
|
|
194
|
+
classifier_expected: Record<string, unknown>;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const INVARIANTS: Invariant[] = [
|
|
198
|
+
{ query: "dhcp server", limit: 8, classifier_expected: { topics: ["dhcp"] } },
|
|
199
|
+
{
|
|
200
|
+
query: "bridge vlan",
|
|
201
|
+
limit: 8,
|
|
202
|
+
classifier_expected: { command_path: "/bridge/vlan", topics: ["bridge", "vlan"] },
|
|
203
|
+
},
|
|
204
|
+
{ query: "hAP ax3", limit: 8, classifier_expected: { device: "hAP" } },
|
|
205
|
+
{
|
|
206
|
+
query: "what changed in 7.22.1",
|
|
207
|
+
limit: 8,
|
|
208
|
+
classifier_expected: { version: "7.22.1" },
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
query: "/ip/firewall/filter",
|
|
212
|
+
limit: 8,
|
|
213
|
+
classifier_expected: {
|
|
214
|
+
command_path: "/ip/firewall/filter",
|
|
215
|
+
topics: ["ip", "firewall", "filter"],
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
];
|
|
219
|
+
|
|
220
|
+
for (const { query, limit, classifier_expected } of INVARIANTS) {
|
|
221
|
+
test(`shape: "${query}"`, () => {
|
|
222
|
+
const result = searchAll(query, limit);
|
|
223
|
+
|
|
224
|
+
// Top-level keys
|
|
225
|
+
expect(result).toHaveProperty("query", query);
|
|
226
|
+
expect(result).toHaveProperty("classified");
|
|
227
|
+
expect(result).toHaveProperty("pages");
|
|
228
|
+
expect(result).toHaveProperty("related");
|
|
229
|
+
expect(result).toHaveProperty("next_steps");
|
|
230
|
+
expect(result).toHaveProperty("total_pages");
|
|
231
|
+
|
|
232
|
+
// Classifier output is DB-independent (pure regex) — assert exact subset
|
|
233
|
+
for (const [k, v] of Object.entries(classifier_expected)) {
|
|
234
|
+
expect(result.classified).toHaveProperty(k, v);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Pages: at least one hit on a populated DB, bounded by limit
|
|
238
|
+
expect(Array.isArray(result.pages)).toBe(true);
|
|
239
|
+
expect(result.pages.length).toBeGreaterThan(0);
|
|
240
|
+
expect(result.pages.length).toBeLessThanOrEqual(limit);
|
|
241
|
+
expect(result.total_pages).toBeGreaterThanOrEqual(result.pages.length);
|
|
242
|
+
for (const page of result.pages) {
|
|
243
|
+
expect(page).toHaveProperty("id");
|
|
244
|
+
expect(page).toHaveProperty("title");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Related block: always an object; buckets that are present are arrays
|
|
248
|
+
expect(typeof result.related).toBe("object");
|
|
249
|
+
for (const [bucket, entries] of Object.entries(result.related)) {
|
|
250
|
+
if (Array.isArray(entries)) {
|
|
251
|
+
expect(entries.length).toBeGreaterThan(0);
|
|
252
|
+
} else {
|
|
253
|
+
// command_node is a single object when present
|
|
254
|
+
expect(entries).toBeTruthy();
|
|
255
|
+
}
|
|
256
|
+
expect(bucket.length).toBeGreaterThan(0);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// next_steps: array of hint strings
|
|
260
|
+
expect(Array.isArray(result.next_steps)).toBe(true);
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
});
|
package/src/release.test.ts
CHANGED
|
@@ -390,9 +390,20 @@ describe("release.yml", () => {
|
|
|
390
390
|
const src = readText(".github/workflows/release.yml");
|
|
391
391
|
expect(src).toContain("bun run typecheck");
|
|
392
392
|
expect(src).toContain("bun test");
|
|
393
|
+
expect(src).toContain("bun test src/mcp-contract.test.ts");
|
|
393
394
|
expect(src).toContain("bun run lint");
|
|
394
395
|
});
|
|
395
396
|
|
|
397
|
+
test("runs MCP contract tests against the real built DB before eval/release", () => {
|
|
398
|
+
const src = readText(".github/workflows/release.yml");
|
|
399
|
+
const contractIdx = src.indexOf("bun test src/mcp-contract.test.ts");
|
|
400
|
+
const evalIdx = src.indexOf("MCP retrieval eval (Phase 0, non-blocking)");
|
|
401
|
+
const buildIdx = src.indexOf("Build release artifacts");
|
|
402
|
+
expect(contractIdx).toBeGreaterThan(0);
|
|
403
|
+
expect(contractIdx).toBeLessThan(evalIdx);
|
|
404
|
+
expect(contractIdx).toBeLessThan(buildIdx);
|
|
405
|
+
});
|
|
406
|
+
|
|
396
407
|
test("creates GitHub Release", () => {
|
|
397
408
|
const src = readText(".github/workflows/release.yml");
|
|
398
409
|
expect(src).toContain("gh release create");
|