@tikoci/rosetta 0.8.6 → 0.8.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/rosetta",
3
- "version": "0.8.6",
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",
package/src/browse.ts CHANGED
@@ -51,6 +51,7 @@ import {
51
51
  searchDude,
52
52
  searchProperties,
53
53
  searchVideos,
54
+ truncateDeviceTestResultsPrefer512,
54
55
  } from "./query.ts";
55
56
 
56
57
  // ── ANSI utilities (zero deps) ──
@@ -785,15 +786,16 @@ function renderDeviceCard(d: DeviceResult): string {
785
786
 
786
787
  // Test results (attached for exact matches)
787
788
  if (d.test_results && d.test_results.length > 0) {
789
+ const truncated = truncateDeviceTestResultsPrefer512(d.test_results, 12);
788
790
  out.push("");
789
791
  out.push(` ${bold("Benchmarks:")} ${dim(`(${d.test_results.length} tests)`)}`);
790
- for (const t of d.test_results.slice(0, 12)) {
792
+ for (const t of truncated.rows) {
791
793
  const mbps = t.throughput_mbps ? `${fmt(t.throughput_mbps)} Mbps` : "";
792
794
  const kpps = t.throughput_kpps ? `${fmt(t.throughput_kpps)} Kpps` : "";
793
795
  out.push(` ${dim(pad(t.test_type, 9))} ${pad(t.mode, 16)} ${dim(pad(t.configuration, 28))} ${pad(`${t.packet_size}B`, 6)} ${bold(mbps)} ${dim(kpps)}`);
794
796
  }
795
- if (d.test_results.length > 12) {
796
- out.push(` ${dim(`... and ${d.test_results.length - 12} more (use`)} ${cyan("tests")} ${dim("for full listing)")}`);
797
+ if (truncated.omitted > 0) {
798
+ out.push(` ${dim(`... and ${truncated.omitted} more (use`)} ${cyan("tests")} ${dim("for full listing)")}`);
797
799
  }
798
800
  }
799
801
 
@@ -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
+ }