@wei840222/qmd 2026.8.23

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.
Files changed (94) hide show
  1. package/CHANGELOG.md +1373 -0
  2. package/LICENSE +45 -0
  3. package/README.md +1439 -0
  4. package/THIRD_PARTY_NOTICES.md +31 -0
  5. package/bin/qmd +192 -0
  6. package/dist/ast.d.ts +65 -0
  7. package/dist/ast.js +334 -0
  8. package/dist/bench/bench.d.ts +35 -0
  9. package/dist/bench/bench.js +338 -0
  10. package/dist/bench/cjk-baseline.d.ts +36 -0
  11. package/dist/bench/cjk-baseline.js +111 -0
  12. package/dist/bench/fixture.d.ts +2 -0
  13. package/dist/bench/fixture.js +84 -0
  14. package/dist/bench/score.d.ts +38 -0
  15. package/dist/bench/score.js +107 -0
  16. package/dist/bench/types.d.ts +110 -0
  17. package/dist/bench/types.js +8 -0
  18. package/dist/cli/build-info.json +4 -0
  19. package/dist/cli/embed-lock.d.ts +24 -0
  20. package/dist/cli/embed-lock.js +94 -0
  21. package/dist/cli/embedding-owner.d.ts +10 -0
  22. package/dist/cli/embedding-owner.js +20 -0
  23. package/dist/cli/formatter.d.ts +120 -0
  24. package/dist/cli/formatter.js +355 -0
  25. package/dist/cli/mcp-pid.d.ts +25 -0
  26. package/dist/cli/mcp-pid.js +86 -0
  27. package/dist/cli/qmd.d.ts +72 -0
  28. package/dist/cli/qmd.js +4806 -0
  29. package/dist/cli/version.d.ts +42 -0
  30. package/dist/cli/version.js +80 -0
  31. package/dist/collections.d.ts +200 -0
  32. package/dist/collections.js +433 -0
  33. package/dist/db.d.ts +65 -0
  34. package/dist/db.js +143 -0
  35. package/dist/diagnostics.d.ts +62 -0
  36. package/dist/diagnostics.js +260 -0
  37. package/dist/embedding/config.d.ts +52 -0
  38. package/dist/embedding/config.js +229 -0
  39. package/dist/embedding/identity.d.ts +58 -0
  40. package/dist/embedding/identity.js +321 -0
  41. package/dist/embedding/local-identity.d.ts +1 -0
  42. package/dist/embedding/local-identity.js +15 -0
  43. package/dist/embedding/local.d.ts +34 -0
  44. package/dist/embedding/local.js +290 -0
  45. package/dist/embedding/openai.d.ts +79 -0
  46. package/dist/embedding/openai.js +477 -0
  47. package/dist/embedding/owner.d.ts +13 -0
  48. package/dist/embedding/owner.js +36 -0
  49. package/dist/embedding/provider.d.ts +68 -0
  50. package/dist/embedding/provider.js +16 -0
  51. package/dist/embedding/remote-chunking.d.ts +22 -0
  52. package/dist/embedding/remote-chunking.js +83 -0
  53. package/dist/embedding/remote-embedding.d.ts +15 -0
  54. package/dist/embedding/remote-embedding.js +77 -0
  55. package/dist/hybrid-llm.d.ts +18 -0
  56. package/dist/hybrid-llm.js +53 -0
  57. package/dist/index.d.ts +244 -0
  58. package/dist/index.js +418 -0
  59. package/dist/llm.d.ts +566 -0
  60. package/dist/llm.js +1847 -0
  61. package/dist/maintenance.d.ts +33 -0
  62. package/dist/maintenance.js +52 -0
  63. package/dist/mcp/origin-guard.d.ts +67 -0
  64. package/dist/mcp/origin-guard.js +137 -0
  65. package/dist/mcp/server.d.ts +116 -0
  66. package/dist/mcp/server.js +919 -0
  67. package/dist/paths.d.ts +1 -0
  68. package/dist/paths.js +4 -0
  69. package/dist/remote-llm.d.ts +52 -0
  70. package/dist/remote-llm.js +464 -0
  71. package/dist/search/cjk-analyzer.d.ts +33 -0
  72. package/dist/search/cjk-analyzer.js +158 -0
  73. package/dist/search/cjk-index.d.ts +104 -0
  74. package/dist/search/cjk-index.js +1031 -0
  75. package/dist/search/jieba-loader.d.ts +23 -0
  76. package/dist/search/jieba-loader.js +79 -0
  77. package/dist/search/query-expansion.d.ts +23 -0
  78. package/dist/search/query-expansion.js +43 -0
  79. package/dist/search/zh-dict.txt +624013 -0
  80. package/dist/store.d.ts +1218 -0
  81. package/dist/store.js +6076 -0
  82. package/dist/trust.d.ts +152 -0
  83. package/dist/trust.js +249 -0
  84. package/package.json +139 -0
  85. package/scripts/build.mjs +83 -0
  86. package/scripts/check-package-grammars.mjs +29 -0
  87. package/scripts/package-smoke.mjs +205 -0
  88. package/scripts/sync-zh-dict.mjs +187 -0
  89. package/scripts/test-all.mjs +45 -0
  90. package/skills/qmd/SKILL.md +324 -0
  91. package/skills/qmd/references/mcp-setup.md +119 -0
  92. package/skills/release/SKILL.md +141 -0
  93. package/skills/release/scripts/install-hooks.sh +38 -0
  94. package/skills/release/scripts/release-context.sh +129 -0
@@ -0,0 +1,338 @@
1
+ /**
2
+ * QMD Benchmark Harness
3
+ *
4
+ * Runs queries from a fixture file against multiple search backends
5
+ * and measures precision@k, recall, MRR, F1, and latency.
6
+ *
7
+ * Usage:
8
+ * qmd bench <fixture.json> [--json] [--collection <name>]
9
+ *
10
+ * Backends tested:
11
+ * - bm25: BM25 keyword search (searchLex)
12
+ * - vector: Vector similarity search (searchVector)
13
+ * - hybrid: BM25 + vector RRF fusion without reranking
14
+ * - full: Full hybrid pipeline with LLM reranking
15
+ */
16
+ import { readFileSync } from "node:fs";
17
+ import { resolve } from "node:path";
18
+ import { createStore, getDefaultDbPath, } from "../index.js";
19
+ import { resolveFixtureQueries } from "./fixture.js";
20
+ import { scoreResults } from "./score.js";
21
+ function parseStructuredQuery(query) {
22
+ const lines = query.split("\n").map((line, idx) => ({
23
+ trimmed: line.trim(),
24
+ number: idx + 1,
25
+ })).filter(line => line.trimmed.length > 0);
26
+ if (lines.length === 0)
27
+ return undefined;
28
+ const prefixRe = /^(lex|vec|hyde):\s*/i;
29
+ const intentRe = /^intent:\s*/i;
30
+ const searches = [];
31
+ let intent;
32
+ for (const line of lines) {
33
+ if (intentRe.test(line.trimmed)) {
34
+ if (intent !== undefined) {
35
+ throw new Error(`Line ${line.number}: only one intent: line is allowed per benchmark query.`);
36
+ }
37
+ intent = line.trimmed.replace(intentRe, "").trim();
38
+ if (!intent) {
39
+ throw new Error(`Line ${line.number}: intent: must include text.`);
40
+ }
41
+ continue;
42
+ }
43
+ const match = line.trimmed.match(prefixRe);
44
+ if (match) {
45
+ const type = match[1].toLowerCase();
46
+ const text = line.trimmed.slice(match[0].length).trim();
47
+ if (!text) {
48
+ throw new Error(`Line ${line.number} (${type}:) must include text.`);
49
+ }
50
+ searches.push({ type, query: text, line: line.number });
51
+ continue;
52
+ }
53
+ if (lines.length === 1) {
54
+ return undefined;
55
+ }
56
+ throw new Error(`Line ${line.number} is missing a lex:/vec:/hyde:/intent: prefix.`);
57
+ }
58
+ if (intent && searches.length === 0) {
59
+ throw new Error("intent: cannot appear alone. Add at least one lex:, vec:, or hyde: line.");
60
+ }
61
+ return searches.length > 0 ? { searches, intent } : undefined;
62
+ }
63
+ function uniqueFiles(files, limit) {
64
+ const seen = new Set();
65
+ const out = [];
66
+ for (const file of files) {
67
+ if (seen.has(file))
68
+ continue;
69
+ seen.add(file);
70
+ out.push(file);
71
+ if (out.length >= limit)
72
+ break;
73
+ }
74
+ return out;
75
+ }
76
+ const BACKENDS = [
77
+ {
78
+ name: "bm25",
79
+ run: async (store, query, limit, collection) => {
80
+ const structured = parseStructuredQuery(query.query);
81
+ const lexQueries = structured?.searches.filter(q => q.type === "lex");
82
+ if (structured) {
83
+ const files = [];
84
+ for (const lex of lexQueries ?? []) {
85
+ const results = await store.searchLex(lex.query, { limit, collection });
86
+ files.push(...results.map((r) => r.filepath));
87
+ }
88
+ return uniqueFiles(files, limit);
89
+ }
90
+ const results = await store.searchLex(query.query, { limit, collection });
91
+ return results.map((r) => r.filepath);
92
+ },
93
+ },
94
+ {
95
+ name: "vector",
96
+ run: async (store, query, limit, collection) => {
97
+ const structured = parseStructuredQuery(query.query);
98
+ const vectorQueries = structured?.searches.filter(q => q.type === "vec" || q.type === "hyde");
99
+ if (structured) {
100
+ const files = [];
101
+ for (const vectorQuery of vectorQueries ?? []) {
102
+ const results = await store.searchVector(vectorQuery.query, { limit, collection });
103
+ files.push(...results.map((r) => r.filepath));
104
+ }
105
+ return uniqueFiles(files, limit);
106
+ }
107
+ const results = await store.searchVector(query.query, { limit, collection });
108
+ return results.map((r) => r.filepath);
109
+ },
110
+ },
111
+ {
112
+ name: "hybrid",
113
+ run: async (store, query, limit, collection) => {
114
+ const structured = parseStructuredQuery(query.query);
115
+ const results = structured
116
+ ? await store.search({ queries: structured.searches, rerankContext: structured.intent, limit, collection, rerank: false })
117
+ : await store.search({ query: query.query, limit, collection, rerank: false });
118
+ return results.map((r) => r.file);
119
+ },
120
+ },
121
+ {
122
+ name: "full",
123
+ run: async (store, query, limit, collection) => {
124
+ const structured = parseStructuredQuery(query.query);
125
+ const results = structured
126
+ ? await store.search({ queries: structured.searches, rerankContext: structured.intent, limit, collection, rerank: true })
127
+ : await store.search({ query: query.query, limit, collection, rerank: true });
128
+ return results.map((r) => r.file);
129
+ },
130
+ },
131
+ ];
132
+ async function runQuery(store, backend, query, collection) {
133
+ const limit = Math.max(query.expected_in_top_k, 10);
134
+ const start = Date.now();
135
+ let resultFiles;
136
+ try {
137
+ resultFiles = await backend.run(store, query, limit, collection);
138
+ }
139
+ catch {
140
+ // Backend may not be available (e.g., no embeddings for vector search)
141
+ return {
142
+ precision_at_k: 0,
143
+ recall: 0,
144
+ recall_at_1: 0,
145
+ recall_at_3: 0,
146
+ recall_at_5: 0,
147
+ recall_at_10: 0,
148
+ mrr: 0,
149
+ mrr_at_10: 0,
150
+ f1: 0,
151
+ hits_at_k: 0,
152
+ false_positive_count: 0,
153
+ false_positive_files: [],
154
+ total_expected: query.expected_files.length,
155
+ latency_ms: Date.now() - start,
156
+ top_files: [],
157
+ matched_files: [],
158
+ unmatched_expected_files: query.expected_files,
159
+ };
160
+ }
161
+ const latency_ms = Date.now() - start;
162
+ const scores = scoreResults(resultFiles, query.expected_files, query.expected_in_top_k, query.must_not_match_files);
163
+ return {
164
+ ...scores,
165
+ total_expected: query.expected_files.length,
166
+ latency_ms,
167
+ top_files: resultFiles.slice(0, 10),
168
+ };
169
+ }
170
+ function formatTable(results) {
171
+ const lines = [];
172
+ const pad = (s, n) => s.slice(0, n).padEnd(n);
173
+ const num = (n) => n.toFixed(2).padStart(5);
174
+ lines.push(`${pad("Query", 25)} ${pad("Backend", 8)} ${pad("P@k", 6)} ${pad("R@1", 6)} ${pad("R@3", 6)} ${pad("R@5", 6)} ${pad("R@10", 6)} ${pad("MRR@10", 7)} ${pad("FP", 4)} ${pad("F1", 6)} ${pad("ms", 8)}`);
175
+ lines.push("-".repeat(108));
176
+ for (const r of results) {
177
+ for (const [backend, br] of Object.entries(r.backends)) {
178
+ lines.push(`${pad(r.id, 25)} ${pad(backend, 8)} ${num(br.precision_at_k)} ${num(br.recall_at_1)} ${num(br.recall_at_3)} ${num(br.recall_at_5)} ${num(br.recall_at_10)} ${num(br.mrr_at_10)} ${String(br.false_positive_count).padStart(3)} ${num(br.f1)} ${String(Math.round(br.latency_ms)).padStart(7)}ms`);
179
+ }
180
+ lines.push("");
181
+ }
182
+ return lines.join("\n");
183
+ }
184
+ function computeSummary(results) {
185
+ const summary = {};
186
+ // Collect all backend names
187
+ const backendNames = new Set();
188
+ for (const r of results) {
189
+ for (const name of Object.keys(r.backends)) {
190
+ backendNames.add(name);
191
+ }
192
+ }
193
+ for (const name of Array.from(backendNames)) {
194
+ let totalP = 0, totalR = 0, totalR1 = 0, totalR3 = 0, totalR5 = 0, totalR10 = 0;
195
+ let totalMrr = 0, totalMrr10 = 0, totalFp = 0, totalF1 = 0, totalLat = 0, count = 0;
196
+ for (const r of results) {
197
+ const br = r.backends[name];
198
+ if (!br)
199
+ continue;
200
+ totalP += br.precision_at_k;
201
+ totalR += br.recall;
202
+ totalR1 += br.recall_at_1;
203
+ totalR3 += br.recall_at_3;
204
+ totalR5 += br.recall_at_5;
205
+ totalR10 += br.recall_at_10;
206
+ totalMrr += br.mrr;
207
+ totalMrr10 += br.mrr_at_10;
208
+ totalFp += br.false_positive_count;
209
+ totalF1 += br.f1;
210
+ totalLat += br.latency_ms;
211
+ count++;
212
+ }
213
+ if (count > 0) {
214
+ summary[name] = {
215
+ avg_precision: totalP / count,
216
+ avg_recall: totalR / count,
217
+ avg_recall_at_1: totalR1 / count,
218
+ avg_recall_at_3: totalR3 / count,
219
+ avg_recall_at_5: totalR5 / count,
220
+ avg_recall_at_10: totalR10 / count,
221
+ avg_mrr: totalMrr / count,
222
+ avg_mrr_at_10: totalMrr10 / count,
223
+ false_positive_count: totalFp,
224
+ avg_f1: totalF1 / count,
225
+ avg_latency_ms: totalLat / count,
226
+ };
227
+ }
228
+ }
229
+ return summary;
230
+ }
231
+ /**
232
+ * Fail fast when the fixture's collection is missing or empty so `qmd bench`
233
+ * does not spend minutes printing a wall of 0.00 (#716).
234
+ */
235
+ export function assertBenchCollectionReady(collections, collection) {
236
+ if (collection) {
237
+ const match = collections.find(c => c.name === collection);
238
+ if (!match) {
239
+ const names = collections.map(c => c.name);
240
+ const hint = names.length > 0
241
+ ? `Available: ${names.join(", ")}. Run 'qmd ls' to inspect.`
242
+ : "Run 'qmd ls' to see available collections.";
243
+ throw new Error(`Collection not found: ${collection}\n${hint}`);
244
+ }
245
+ if (match.active_count === 0) {
246
+ throw new Error(`Collection '${collection}' has no indexed documents.\nRun 'qmd update', then 'qmd ls ${collection}' to confirm files are indexed before bench.`);
247
+ }
248
+ return;
249
+ }
250
+ const total = collections.reduce((n, c) => n + c.active_count, 0);
251
+ if (collections.length === 0 || total === 0) {
252
+ throw new Error("No indexed documents found.\nIndex a collection with 'qmd collection add' / 'qmd update' before running bench.");
253
+ }
254
+ }
255
+ export function benchSummaryAllZero(summary) {
256
+ const entries = Object.values(summary);
257
+ if (entries.length === 0)
258
+ return false;
259
+ return entries.every(s => s.avg_precision === 0 && s.avg_recall === 0 && s.avg_mrr === 0);
260
+ }
261
+ export function allZeroBenchWarning(collection) {
262
+ const lsHint = collection ? `qmd ls ${collection}` : "qmd ls";
263
+ return `All benchmark scores were 0.00 — the collection is likely unindexed or the fixture's expected files are missing. Check with '${lsHint}'.`;
264
+ }
265
+ export async function runBenchmark(fixturePath, options = {}) {
266
+ // Load fixture
267
+ const raw = readFileSync(resolve(fixturePath), "utf-8");
268
+ const fixture = JSON.parse(raw);
269
+ if (!fixture.queries || !Array.isArray(fixture.queries)) {
270
+ throw new Error("Invalid fixture: missing 'queries' array");
271
+ }
272
+ const queries = resolveFixtureQueries(fixture);
273
+ // Open store
274
+ const store = await createStore({
275
+ dbPath: options.dbPath ?? getDefaultDbPath(),
276
+ ...(options.configPath ? { configPath: options.configPath } : {}),
277
+ ...(options.config ? { config: options.config } : {}),
278
+ });
279
+ // Filter backends if requested
280
+ const activeBackends = options.backends
281
+ ? BACKENDS.filter(b => options.backends.includes(b.name))
282
+ : BACKENDS;
283
+ const collection = options.collection ?? fixture.collection;
284
+ const results = [];
285
+ try {
286
+ const collections = await store.listCollections();
287
+ assertBenchCollectionReady(collections, collection);
288
+ // Run queries
289
+ for (const query of queries) {
290
+ const backends = {};
291
+ for (const backend of activeBackends) {
292
+ if (!options.json) {
293
+ process.stderr.write(` ${query.id} / ${backend.name}...`);
294
+ }
295
+ backends[backend.name] = await runQuery(store, backend, query, collection);
296
+ if (!options.json) {
297
+ process.stderr.write(` ${Math.round(backends[backend.name].latency_ms)}ms\n`);
298
+ }
299
+ }
300
+ results.push({
301
+ id: query.id,
302
+ query: query.query,
303
+ type: query.type,
304
+ ...(query.scenario_tags ? { scenario_tags: query.scenario_tags } : {}),
305
+ backends,
306
+ });
307
+ }
308
+ }
309
+ finally {
310
+ await store.close();
311
+ }
312
+ const summary = computeSummary(results);
313
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15);
314
+ const benchResult = {
315
+ timestamp,
316
+ fixture: fixturePath,
317
+ results,
318
+ summary,
319
+ };
320
+ // Output
321
+ if (benchSummaryAllZero(summary)) {
322
+ process.stderr.write(`\n${allZeroBenchWarning(collection)}\n`);
323
+ }
324
+ if (options.json) {
325
+ console.log(JSON.stringify(benchResult, null, 2));
326
+ }
327
+ else {
328
+ console.log("\n" + formatTable(results));
329
+ console.log("Summary:");
330
+ console.log("-".repeat(70));
331
+ const pad = (s, n) => s.slice(0, n).padEnd(n);
332
+ const num = (n) => n.toFixed(3).padStart(6);
333
+ for (const [name, s] of Object.entries(summary)) {
334
+ console.log(` ${pad(name, 8)} P@k=${num(s.avg_precision)} R@1=${num(s.avg_recall_at_1)} R@3=${num(s.avg_recall_at_3)} R@5=${num(s.avg_recall_at_5)} R@10=${num(s.avg_recall_at_10)} MRR@10=${num(s.avg_mrr_at_10)} FP=${s.false_positive_count} F1=${num(s.avg_f1)} Avg=${Math.round(s.avg_latency_ms)}ms`);
335
+ }
336
+ }
337
+ return benchResult;
338
+ }
@@ -0,0 +1,36 @@
1
+ import type { BenchmarkFixture } from "./types.js";
2
+ export declare const CJK_BASELINE_SCORING_SCHEMA = "qmd-char-bm25-v1";
3
+ export declare const CJK_LEXICAL_SCORING_SCHEMA = "qmd-cjk-lexical-rrf-v1";
4
+ export interface LexicalBaselineStore {
5
+ searchCharLex(query: string, options: {
6
+ limit: number;
7
+ collection?: string;
8
+ }): Promise<Array<{
9
+ filepath: string;
10
+ }>>;
11
+ }
12
+ export interface LexicalBaselineOptions {
13
+ warmupRuns?: number;
14
+ measuredRuns?: number;
15
+ scoringSchema?: string;
16
+ }
17
+ export interface LexicalBaselineObservation {
18
+ corpus_sha256: string;
19
+ scoring_schema: string;
20
+ runs: {
21
+ warmup: number;
22
+ measured: number;
23
+ };
24
+ metrics: {
25
+ recall_at_10: number;
26
+ mrr_at_10: number;
27
+ shared_character_false_positive_count: number;
28
+ };
29
+ latency_ms: {
30
+ p50: number;
31
+ p95: number;
32
+ };
33
+ }
34
+ export declare function hashBenchmarkCorpus(fixture: BenchmarkFixture, documentsDirectory: string): string;
35
+ export declare function hashBaselineImplementation(projectRoot: string): string;
36
+ export declare function observeLexicalBaseline(store: LexicalBaselineStore, fixture: BenchmarkFixture, documentsDirectory: string, options?: LexicalBaselineOptions): Promise<LexicalBaselineObservation>;
@@ -0,0 +1,111 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { resolveFixtureQueries } from "./fixture.js";
5
+ import { percentile, scoreResults } from "./score.js";
6
+ export const CJK_BASELINE_SCORING_SCHEMA = "qmd-char-bm25-v1";
7
+ export const CJK_LEXICAL_SCORING_SCHEMA = "qmd-cjk-lexical-rrf-v1";
8
+ const CJK_BASELINE_IMPLEMENTATION_FILES = [
9
+ "src/bench/cjk-baseline.ts",
10
+ "src/bench/fixture.ts",
11
+ "src/bench/score.ts",
12
+ "src/db.ts",
13
+ "src/store.ts",
14
+ ];
15
+ function positiveInteger(value, name) {
16
+ if (!Number.isInteger(value) || value <= 0) {
17
+ throw new RangeError(`${name} must be a positive integer`);
18
+ }
19
+ return value;
20
+ }
21
+ function roundMetric(value) {
22
+ return Number(value.toFixed(6));
23
+ }
24
+ export function hashBenchmarkCorpus(fixture, documentsDirectory) {
25
+ if (!fixture.documents?.length) {
26
+ throw new Error("Benchmark fixture must define documents before its corpus can be hashed");
27
+ }
28
+ const hash = createHash("sha256");
29
+ hash.update(JSON.stringify(fixture));
30
+ for (const document of [...fixture.documents].sort((a, b) => a.id.localeCompare(b.id))) {
31
+ hash.update("\0");
32
+ hash.update(document.id);
33
+ hash.update("\0");
34
+ hash.update(readFileSync(join(documentsDirectory, document.file)));
35
+ }
36
+ return hash.digest("hex");
37
+ }
38
+ export function hashBaselineImplementation(projectRoot) {
39
+ const hash = createHash("sha256");
40
+ for (const file of CJK_BASELINE_IMPLEMENTATION_FILES) {
41
+ hash.update(file);
42
+ hash.update("\0");
43
+ hash.update(readFileSync(join(projectRoot, file)));
44
+ hash.update("\0");
45
+ }
46
+ return hash.digest("hex");
47
+ }
48
+ export async function observeLexicalBaseline(store, fixture, documentsDirectory, options = {}) {
49
+ const warmupRuns = positiveInteger(options.warmupRuns ?? 5, "warmupRuns");
50
+ const measuredRuns = positiveInteger(options.measuredRuns ?? 50, "measuredRuns");
51
+ const queries = resolveFixtureQueries(fixture);
52
+ if (queries.length === 0)
53
+ throw new Error("Benchmark fixture must contain at least one query");
54
+ const runQuery = async (query) => {
55
+ const startedAt = performance.now();
56
+ const results = await store.searchCharLex(query.query, {
57
+ limit: Math.max(query.expected_in_top_k, 10),
58
+ ...(fixture.collection ? { collection: fixture.collection } : {}),
59
+ });
60
+ const latency = performance.now() - startedAt;
61
+ const scores = scoreResults(results.map(result => result.filepath), query.expected_files, 10, query.must_not_match_files);
62
+ return {
63
+ latency,
64
+ ranking: results.slice(0, 10).map(result => result.filepath),
65
+ scores,
66
+ };
67
+ };
68
+ for (let run = 0; run < warmupRuns; run++) {
69
+ for (const query of queries)
70
+ await runQuery(query);
71
+ }
72
+ const latencies = [];
73
+ let totalRecallAt10 = 0;
74
+ let totalMrrAt10 = 0;
75
+ let totalSharedCharacterFalsePositives = 0;
76
+ const firstRankings = new Map();
77
+ for (let run = 0; run < measuredRuns; run++) {
78
+ for (const query of queries) {
79
+ const { latency, ranking, scores } = await runQuery(query);
80
+ const firstRanking = firstRankings.get(query.id);
81
+ if (firstRanking === undefined) {
82
+ firstRankings.set(query.id, ranking);
83
+ }
84
+ else if (firstRanking.length !== ranking.length
85
+ || firstRanking.some((file, index) => file !== ranking[index])) {
86
+ throw new Error(`Lexical ranking changed between measured runs for query '${query.id}'`);
87
+ }
88
+ latencies.push(latency);
89
+ totalRecallAt10 += scores.recall_at_10;
90
+ totalMrrAt10 += scores.mrr_at_10;
91
+ if (query.scenario_tags?.includes("shared-character")) {
92
+ totalSharedCharacterFalsePositives += scores.false_positive_count;
93
+ }
94
+ }
95
+ }
96
+ const measuredQueries = measuredRuns * queries.length;
97
+ return {
98
+ corpus_sha256: hashBenchmarkCorpus(fixture, documentsDirectory),
99
+ scoring_schema: options.scoringSchema ?? CJK_BASELINE_SCORING_SCHEMA,
100
+ runs: { warmup: warmupRuns, measured: measuredRuns },
101
+ metrics: {
102
+ recall_at_10: roundMetric(totalRecallAt10 / measuredQueries),
103
+ mrr_at_10: roundMetric(totalMrrAt10 / measuredQueries),
104
+ shared_character_false_positive_count: roundMetric(totalSharedCharacterFalsePositives / measuredRuns),
105
+ },
106
+ latency_ms: {
107
+ p50: percentile(latencies, 0.5),
108
+ p95: percentile(latencies, 0.95),
109
+ },
110
+ };
111
+ }
@@ -0,0 +1,2 @@
1
+ import type { BenchmarkFixture, ResolvedBenchmarkQuery } from "./types.js";
2
+ export declare function resolveFixtureQueries(fixture: BenchmarkFixture): ResolvedBenchmarkQuery[];
@@ -0,0 +1,84 @@
1
+ function assertUniqueValues(values, label, queryId) {
2
+ const seen = new Set();
3
+ for (const value of values) {
4
+ const scope = queryId ? ` query '${queryId}'` : "";
5
+ if (value.trim().length === 0) {
6
+ throw new Error(`Invalid fixture${scope}: empty ${label}`);
7
+ }
8
+ if (seen.has(value)) {
9
+ throw new Error(`Invalid fixture${scope}: duplicate ${label} '${value}'`);
10
+ }
11
+ seen.add(value);
12
+ }
13
+ }
14
+ function buildDocumentMap(fixture) {
15
+ const documents = new Map();
16
+ const files = new Set();
17
+ for (const document of fixture.documents ?? []) {
18
+ if (document.id.trim().length === 0) {
19
+ throw new Error("Invalid fixture: empty document id");
20
+ }
21
+ if (document.file.trim().length === 0) {
22
+ throw new Error(`Invalid fixture: empty file for document id '${document.id}'`);
23
+ }
24
+ if (documents.has(document.id)) {
25
+ throw new Error(`Invalid fixture: duplicate document id '${document.id}'`);
26
+ }
27
+ if (files.has(document.file)) {
28
+ throw new Error(`Invalid fixture: duplicate document file '${document.file}'`);
29
+ }
30
+ documents.set(document.id, document.file);
31
+ files.add(document.file);
32
+ }
33
+ return documents;
34
+ }
35
+ function resolveDocumentIds(documentIds, documents, queryId) {
36
+ return documentIds.map((documentId) => {
37
+ const file = documents.get(documentId);
38
+ if (!file) {
39
+ throw new Error(`Invalid fixture query '${queryId}': unknown document id '${documentId}'`);
40
+ }
41
+ return file;
42
+ });
43
+ }
44
+ function resolveQuery(query, documents) {
45
+ if (query.expected_files && query.relevant_doc_ids) {
46
+ throw new Error(`Invalid fixture query '${query.id}': use expected_files or relevant_doc_ids, not both`);
47
+ }
48
+ const relevantIds = query.relevant_doc_ids ?? [];
49
+ const mustNotMatchIds = query.must_not_match_doc_ids ?? [];
50
+ assertUniqueValues(relevantIds, "relevant document id", query.id);
51
+ assertUniqueValues(mustNotMatchIds, "must-not-match document id", query.id);
52
+ if (query.expected_files)
53
+ assertUniqueValues(query.expected_files, "expected file", query.id);
54
+ for (const documentId of relevantIds) {
55
+ if (mustNotMatchIds.includes(documentId)) {
56
+ throw new Error(`Invalid fixture query '${query.id}': document id '${documentId}' cannot be both relevant and must-not-match`);
57
+ }
58
+ }
59
+ const expectedFiles = query.expected_files
60
+ ? [...query.expected_files]
61
+ : resolveDocumentIds(relevantIds, documents, query.id);
62
+ if (expectedFiles.length === 0) {
63
+ throw new Error(`Invalid fixture query '${query.id}': no relevant documents`);
64
+ }
65
+ return {
66
+ ...query,
67
+ expected_files: expectedFiles,
68
+ must_not_match_files: resolveDocumentIds(mustNotMatchIds, documents, query.id),
69
+ };
70
+ }
71
+ export function resolveFixtureQueries(fixture) {
72
+ const documents = buildDocumentMap(fixture);
73
+ const queryIds = new Set();
74
+ return fixture.queries.map((query) => {
75
+ if (query.id.trim().length === 0) {
76
+ throw new Error("Invalid fixture: empty query id");
77
+ }
78
+ if (queryIds.has(query.id)) {
79
+ throw new Error(`Invalid fixture: duplicate query id '${query.id}'`);
80
+ }
81
+ queryIds.add(query.id);
82
+ return resolveQuery(query, documents);
83
+ });
84
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Scoring functions for the QMD benchmark harness.
3
+ *
4
+ * Computes precision@k, recall, MRR, and F1 for search results
5
+ * against ground-truth expected files.
6
+ */
7
+ /**
8
+ * Normalize a file path for comparison.
9
+ * Strips qmd:// prefix, lowercases, removes leading/trailing slashes.
10
+ */
11
+ export declare function normalizePath(p: string): string;
12
+ /**
13
+ * Check if two paths refer to the same file.
14
+ * Handles different path formats by comparing normalized suffixes.
15
+ */
16
+ export declare function pathsMatch(result: string, expected: string): boolean;
17
+ export declare function percentile(values: number[], quantile: number): number;
18
+ type ScoreMetrics = {
19
+ precision_at_k: number;
20
+ recall: number;
21
+ recall_at_1: number;
22
+ recall_at_3: number;
23
+ recall_at_5: number;
24
+ recall_at_10: number;
25
+ mrr: number;
26
+ mrr_at_10: number;
27
+ f1: number;
28
+ hits_at_k: number;
29
+ false_positive_count: number;
30
+ false_positive_files: string[];
31
+ matched_files: string[];
32
+ unmatched_expected_files: string[];
33
+ };
34
+ /**
35
+ * Score a set of search results against expected files.
36
+ */
37
+ export declare function scoreResults(resultFiles: string[], expectedFiles: string[], topK: number, mustNotMatchFiles?: string[]): ScoreMetrics;
38
+ export {};