@tikoci/rosetta 0.8.7 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/rosetta",
3
- "version": "0.8.7",
3
+ "version": "0.8.8",
4
4
  "description": "RouterOS documentation as SQLite FTS5 — RAG search + command glossary via MCP",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
+ });
@@ -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");