@tikoci/rosetta 0.8.6 → 0.8.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/browse.ts +5 -3
- package/src/eval/retrieval.ts +469 -0
- package/src/eval/self-supervised.ts +585 -0
- package/src/mcp-contract.test.ts +263 -0
- package/src/query.test.ts +43 -0
- package/src/query.ts +39 -2
- package/src/release.test.ts +11 -0
package/package.json
CHANGED
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
|
|
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 (
|
|
796
|
-
out.push(` ${dim(`... and ${
|
|
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
|
+
}
|