@zosmaai/pi-llm-wiki 0.11.4 → 0.11.6
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/README.de.md +8 -0
- package/README.es.md +8 -0
- package/README.fr.md +8 -0
- package/README.hi.md +8 -0
- package/README.ja.md +8 -0
- package/README.ko.md +8 -0
- package/README.md +8 -0
- package/README.pt.md +8 -0
- package/README.ru.md +8 -0
- package/README.zh.md +8 -0
- package/assets/wiki-dashboard.png +0 -0
- package/commands/wiki-ingest.md +1 -0
- package/commands/wiki-req.md +1 -0
- package/commands/wiki-retro.md +1 -0
- package/dist/extensions/llm-wiki/lib/dashboard-command.js +86 -0
- package/dist/extensions/llm-wiki/lib/dashboard.js +175 -0
- package/dist/extensions/llm-wiki/lib/guardrails.js +30 -1
- package/dist/extensions/llm-wiki/lib/host.js +21 -1
- package/dist/extensions/llm-wiki/lib/ingest-worker.js +44 -20
- package/dist/extensions/llm-wiki/lib/knowledge-document.js +20 -2
- package/dist/extensions/llm-wiki/lib/knowledge-links.js +133 -27
- package/dist/extensions/llm-wiki/lib/metadata.js +6 -6
- package/dist/extensions/llm-wiki/lib/observation.js +22 -3
- package/dist/extensions/llm-wiki/lib/retro.js +38 -4
- package/dist/extensions/llm-wiki/lib/runtime.js +2 -2
- package/dist/extensions/llm-wiki/lib/settings-command.js +377 -0
- package/dist/extensions/llm-wiki/lib/task-config.js +100 -1
- package/dist/extensions/llm-wiki/lib/tools.js +47 -8
- package/dist/mcp/index.js +2 -1
- package/dist/mcp/operations.js +21 -2
- package/docs/api.md +24 -1
- package/docs/commands.md +6 -1
- package/docs/configuration.md +11 -0
- package/docs/obsidian.md +6 -6
- package/docs/superpowers/plans/2026-08-09-qmd-retrieval-phase-1-quality-baseline-and-compatibility.md +1520 -0
- package/docs/superpowers/plans/2026-08-27-wikilink-resolver-normalization.md +735 -0
- package/docs/superpowers/plans/2026-08-29-wikilink-gate-ensure-page-retro.md +642 -0
- package/docs/superpowers/plans/2026-08-29-wikilink-write-validation.md +695 -0
- package/docs/superpowers/roadmaps/2026-08-09-qmd-retrieval-roadmap.md +448 -0
- package/docs/superpowers/specs/2026-08-08-qmd-retrieval-design.md +806 -0
- package/extensions/llm-wiki/index.ts +4 -0
- package/extensions/llm-wiki/lib/dashboard-command.ts +106 -0
- package/extensions/llm-wiki/lib/dashboard.ts +210 -0
- package/extensions/llm-wiki/lib/guardrails.ts +26 -1
- package/extensions/llm-wiki/lib/host.ts +21 -1
- package/extensions/llm-wiki/lib/ingest-worker.ts +64 -27
- package/extensions/llm-wiki/lib/knowledge-document.ts +21 -2
- package/extensions/llm-wiki/lib/knowledge-links.ts +208 -35
- package/extensions/llm-wiki/lib/metadata.ts +10 -6
- package/extensions/llm-wiki/lib/observation.ts +23 -3
- package/extensions/llm-wiki/lib/retro.ts +48 -4
- package/extensions/llm-wiki/lib/runtime.ts +2 -2
- package/extensions/llm-wiki/lib/settings-command.ts +483 -0
- package/extensions/llm-wiki/lib/task-config.ts +138 -0
- package/extensions/llm-wiki/lib/tools.ts +62 -8
- package/mcp/index.ts +12 -1
- package/mcp/operations.ts +32 -2
- package/package.json +4 -4
- package/prompts/wiki-ingest.md +1 -0
- package/prompts/wiki-req.md +1 -0
- package/prompts/wiki-retro.md +1 -0
- package/skills/llm-wiki/SKILL.md +11 -1
|
@@ -0,0 +1,1520 @@
|
|
|
1
|
+
# QMD Retrieval Phase 1: Quality Baseline and Compatibility Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use /skill:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Establish a reproducible current-retrieval benchmark and prove the published QMD 2.5.3 dependency/runtime contract without changing active recall behavior.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Keep production recall on the existing `searchWiki` path throughout this phase. Add a deterministic fixture-driven benchmark around that path, pin QMD as an unused runtime dependency, and exercise QMD's public SDK through isolated contract tests. Ordinary CI validates native installation and model-free lexical behavior; a separate cached workflow validates model-backed calls.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** TypeScript 5.9, Node.js 22+, Vitest 3, QMD 2.5.3, pnpm 9, GitHub Actions, Markdown fixtures.
|
|
10
|
+
|
|
11
|
+
**Roadmap:** [`docs/superpowers/roadmaps/2026-08-09-qmd-retrieval-roadmap.md`](../roadmaps/2026-08-09-qmd-retrieval-roadmap.md)
|
|
12
|
+
|
|
13
|
+
**Phase:** Phase 1: Quality Baseline and QMD Compatibility
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Phase Boundary
|
|
18
|
+
|
|
19
|
+
This plan intentionally does not create persistent QMD vault indexes or route any Pi/MCP/automatic recall through QMD. At completion, current recall remains active and unchanged; QMD is pinned, installable, contract-tested, and ready for Phase 2.
|
|
20
|
+
|
|
21
|
+
## File Map
|
|
22
|
+
|
|
23
|
+
**Create:**
|
|
24
|
+
|
|
25
|
+
- `test/fixtures/retrieval-benchmark/fixture.ts` — sanitized, versioned benchmark pages and 60 graded queries.
|
|
26
|
+
- `test/retrieval-benchmark-fixture.test.ts` — fixture completeness, split, category, identity, and privacy checks.
|
|
27
|
+
- `test/helpers/retrieval-metrics.ts` — pure information-retrieval metric functions.
|
|
28
|
+
- `test/retrieval-metrics.test.ts` — exact metric behavior tests.
|
|
29
|
+
- `test/retrieval-benchmark.test.ts` — materializes the fixture, runs current `searchWiki`, and compares the committed baseline.
|
|
30
|
+
- `scripts/update-retrieval-baseline.mjs` — cross-platform baseline regeneration entry point.
|
|
31
|
+
- `docs/superpowers/benchmarks/phase-1-current-baseline.json` — generated deterministic baseline artifact.
|
|
32
|
+
- `test/qmd-contract.test.ts` — QMD SDK type contract, native lexical smoke, and opt-in model smoke.
|
|
33
|
+
- `.github/workflows/qmd-model-smoke.yml` — scheduled/manual cached model-backed compatibility check.
|
|
34
|
+
- `docs/retrieval-benchmark.md` — benchmark schema, commands, privacy rules, and interpretation.
|
|
35
|
+
- `docs/qmd-compatibility.md` — pinned package/runtime/native/model support contract.
|
|
36
|
+
|
|
37
|
+
**Modify:**
|
|
38
|
+
|
|
39
|
+
- `package.json` — Node floor, exact QMD dependency, TypeScript peer-compatible version, benchmark scripts.
|
|
40
|
+
- `pnpm-lock.yaml` — generated dependency lock update.
|
|
41
|
+
- `test/package-structure.test.ts` — package/runtime assertions.
|
|
42
|
+
- `.github/workflows/ci.yml` — supported Node matrix and cross-platform QMD native-install job.
|
|
43
|
+
|
|
44
|
+
**Explicitly unchanged:**
|
|
45
|
+
|
|
46
|
+
- `extensions/llm-wiki/lib/recall.ts` — remains the active retrieval implementation and benchmark subject.
|
|
47
|
+
- `extensions/llm-wiki/index.ts` — automatic recall behavior stays unchanged.
|
|
48
|
+
- `mcp/operations.ts` — MCP recall behavior stays unchanged.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
### Task 1: Pin the next-major runtime and QMD dependency contract
|
|
53
|
+
|
|
54
|
+
**Files:**
|
|
55
|
+
- Modify: `test/package-structure.test.ts`
|
|
56
|
+
- Modify: `package.json`
|
|
57
|
+
- Modify: `pnpm-lock.yaml`
|
|
58
|
+
|
|
59
|
+
- [ ] **Step 1: Add the failing package-contract assertions**
|
|
60
|
+
|
|
61
|
+
Add these assertions inside `it("should have a valid package.json with pi manifest", ...)`, immediately after parsing `pkg`:
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
expect(pkg.engines.node).toBe(">=22.0.0");
|
|
65
|
+
expect(pkg.dependencies["@tobilu/qmd"]).toBe("2.5.3");
|
|
66
|
+
expect(pkg.devDependencies.typescript).toBe("^5.9.3");
|
|
67
|
+
expect(pkg.pnpm.onlyBuiltDependencies).toEqual([
|
|
68
|
+
"better-sqlite3",
|
|
69
|
+
"node-llama-cpp",
|
|
70
|
+
"sqlite-vec",
|
|
71
|
+
]);
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
- [ ] **Step 2: Run the targeted test and verify the old package contract fails**
|
|
75
|
+
|
|
76
|
+
Run:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
pnpm exec vitest run test/package-structure.test.ts --reporter=verbose
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Expected: FAIL because the current engine is `>=18`, QMD and the native-build whitelist are absent, and TypeScript is still `^5.7.0`.
|
|
83
|
+
|
|
84
|
+
- [ ] **Step 3: Install the exact published QMD package and compatible TypeScript**
|
|
85
|
+
|
|
86
|
+
Run:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
pnpm add --save-exact @tobilu/qmd@2.5.3
|
|
90
|
+
pnpm add --save-dev typescript@^5.9.3
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Expected: `package.json` contains exact QMD `2.5.3`, TypeScript `^5.9.3`, and `pnpm-lock.yaml` records QMD's native dependencies.
|
|
94
|
+
|
|
95
|
+
- [ ] **Step 4: Raise the Node.js engine floor and whitelist QMD native builds**
|
|
96
|
+
|
|
97
|
+
Change only the `engines` block in `package.json` to:
|
|
98
|
+
|
|
99
|
+
```json
|
|
100
|
+
"engines": {
|
|
101
|
+
"node": ">=22.0.0"
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Add a top-level `pnpm` key so pnpm 10+ still runs QMD's native dependency build scripts (QMD's own `pnpm.onlyBuiltDependencies` does not propagate to consumers):
|
|
106
|
+
|
|
107
|
+
```json
|
|
108
|
+
"pnpm": {
|
|
109
|
+
"onlyBuiltDependencies": ["better-sqlite3", "node-llama-cpp", "sqlite-vec"]
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Do not edit `package.json.version`; release versioning belongs to Phase 7 and must use the release script.
|
|
114
|
+
|
|
115
|
+
- [ ] **Step 5: Run package, type, and package-build checks**
|
|
116
|
+
|
|
117
|
+
Run:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
pnpm exec vitest run test/package-structure.test.ts --reporter=verbose
|
|
121
|
+
pnpm typecheck
|
|
122
|
+
pnpm build:mcp
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Expected: all commands exit 0; QMD remains unused by production code. If the TypeScript 5.9 bump surfaces new diagnostics in existing files, commit those fixes separately before the Task 1 commit.
|
|
126
|
+
|
|
127
|
+
- [ ] **Step 6: Commit the runtime contract**
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
git add package.json pnpm-lock.yaml test/package-structure.test.ts
|
|
131
|
+
git commit -m "build: require Node 22 and pin QMD"
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
### Task 2: Add the sanitized 60-query benchmark fixture
|
|
137
|
+
|
|
138
|
+
**Files:**
|
|
139
|
+
- Create: `test/fixtures/retrieval-benchmark/fixture.ts`
|
|
140
|
+
- Create: `test/retrieval-benchmark-fixture.test.ts`
|
|
141
|
+
|
|
142
|
+
- [ ] **Step 1: Write the failing fixture-contract test**
|
|
143
|
+
|
|
144
|
+
Create `test/retrieval-benchmark-fixture.test.ts`:
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
import { describe, expect, it } from "vitest";
|
|
148
|
+
import {
|
|
149
|
+
BENCHMARK_VERSION,
|
|
150
|
+
benchmarkPages,
|
|
151
|
+
benchmarkQueries,
|
|
152
|
+
} from "./fixtures/retrieval-benchmark/fixture.js";
|
|
153
|
+
|
|
154
|
+
describe("retrieval benchmark fixture", () => {
|
|
155
|
+
it("contains the approved versioned 60-query train/held-out corpus", () => {
|
|
156
|
+
expect(BENCHMARK_VERSION).toBe(1);
|
|
157
|
+
expect(benchmarkPages).toHaveLength(22);
|
|
158
|
+
expect(benchmarkQueries).toHaveLength(60);
|
|
159
|
+
expect(benchmarkQueries.filter((query) => query.split === "train")).toHaveLength(45);
|
|
160
|
+
expect(benchmarkQueries.filter((query) => query.split === "heldout")).toHaveLength(15);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("covers every Phase 1 query category", () => {
|
|
164
|
+
expect(new Set(benchmarkQueries.map((query) => query.category))).toEqual(
|
|
165
|
+
new Set([
|
|
166
|
+
"exact_lookup",
|
|
167
|
+
"entity_alias",
|
|
168
|
+
"paraphrase",
|
|
169
|
+
"vague_recollection",
|
|
170
|
+
"conceptual",
|
|
171
|
+
"graph_scope",
|
|
172
|
+
"evidence_request",
|
|
173
|
+
"temporal",
|
|
174
|
+
"contradiction",
|
|
175
|
+
"conclusion",
|
|
176
|
+
"synthesis",
|
|
177
|
+
"negative",
|
|
178
|
+
]),
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("uses unique stable ids and judgments that reference fixture pages", () => {
|
|
183
|
+
const pageIds = new Set(benchmarkPages.map((page) => page.id));
|
|
184
|
+
const queryIds = benchmarkQueries.map((query) => query.id);
|
|
185
|
+
expect(new Set(queryIds).size).toBe(queryIds.length);
|
|
186
|
+
|
|
187
|
+
for (const query of benchmarkQueries) {
|
|
188
|
+
expect(query.judgments.length > 0 || query.autoExpectation === "none").toBe(true);
|
|
189
|
+
for (const judgment of query.judgments) expect(pageIds.has(judgment.pageId)).toBe(true);
|
|
190
|
+
for (const pageId of query.expectedConflicts ?? []) expect(pageIds.has(pageId)).toBe(true);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("contains no raw home paths, email addresses, or token-shaped secrets", () => {
|
|
195
|
+
const serialized = JSON.stringify({ benchmarkPages, benchmarkQueries });
|
|
196
|
+
expect(serialized).not.toMatch(/\/home\//i);
|
|
197
|
+
expect(serialized).not.toMatch(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i);
|
|
198
|
+
expect(serialized).not.toMatch(/\b(?:sk|ghp|github_pat)_[A-Za-z0-9_-]{16,}\b/);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
- [ ] **Step 2: Run the fixture test and verify the module is missing**
|
|
204
|
+
|
|
205
|
+
Run:
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
pnpm exec vitest run test/retrieval-benchmark-fixture.test.ts --reporter=verbose
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Expected: FAIL with a module-resolution error for `fixtures/retrieval-benchmark/fixture.js`.
|
|
212
|
+
|
|
213
|
+
- [ ] **Step 3: Create the complete benchmark fixture**
|
|
214
|
+
|
|
215
|
+
Create `test/fixtures/retrieval-benchmark/fixture.ts`:
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
export const BENCHMARK_VERSION = 1;
|
|
219
|
+
|
|
220
|
+
export type BenchmarkRole = "canonical" | "evidence";
|
|
221
|
+
export type BenchmarkSplit = "train" | "heldout";
|
|
222
|
+
export type AutoExpectation = "hit" | "none";
|
|
223
|
+
|
|
224
|
+
export interface BenchmarkPage {
|
|
225
|
+
id: string;
|
|
226
|
+
type: string;
|
|
227
|
+
title: string;
|
|
228
|
+
body: string;
|
|
229
|
+
aliases?: string[];
|
|
230
|
+
tags?: string[];
|
|
231
|
+
status?: "draft" | "stable" | "deprecated";
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export interface BenchmarkJudgment {
|
|
235
|
+
pageId: string;
|
|
236
|
+
grade: 1 | 2 | 3;
|
|
237
|
+
role: BenchmarkRole;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export interface BenchmarkQuery {
|
|
241
|
+
id: string;
|
|
242
|
+
text: string;
|
|
243
|
+
category:
|
|
244
|
+
| "exact_lookup"
|
|
245
|
+
| "entity_alias"
|
|
246
|
+
| "paraphrase"
|
|
247
|
+
| "vague_recollection"
|
|
248
|
+
| "conceptual"
|
|
249
|
+
| "graph_scope"
|
|
250
|
+
| "evidence_request"
|
|
251
|
+
| "temporal"
|
|
252
|
+
| "contradiction"
|
|
253
|
+
| "conclusion"
|
|
254
|
+
| "synthesis"
|
|
255
|
+
| "negative";
|
|
256
|
+
split: BenchmarkSplit;
|
|
257
|
+
judgments: BenchmarkJudgment[];
|
|
258
|
+
autoExpectation: AutoExpectation;
|
|
259
|
+
expectedConflicts?: string[];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export const benchmarkPages: BenchmarkPage[] = [
|
|
263
|
+
{
|
|
264
|
+
id: "entities/qmd",
|
|
265
|
+
type: "entity",
|
|
266
|
+
title: "QMD",
|
|
267
|
+
aliases: ["Query Markup Documents"],
|
|
268
|
+
tags: ["retrieval", "markdown"],
|
|
269
|
+
status: "stable",
|
|
270
|
+
body: "QMD is an on-device Markdown search engine combining BM25, vector retrieval, reciprocal-rank fusion, and local reranking.",
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
id: "concepts/reciprocal-rank-fusion",
|
|
274
|
+
type: "concept",
|
|
275
|
+
title: "Reciprocal Rank Fusion",
|
|
276
|
+
aliases: ["RRF"],
|
|
277
|
+
tags: ["retrieval", "ranking"],
|
|
278
|
+
body: "Reciprocal Rank Fusion combines independently ranked lists by rank instead of adding incomparable BM25 and cosine scores.",
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
id: "concepts/hybrid-retrieval",
|
|
282
|
+
type: "concept",
|
|
283
|
+
title: "Hybrid Retrieval",
|
|
284
|
+
tags: ["bm25", "embeddings"],
|
|
285
|
+
body: "Hybrid retrieval joins lexical matching for exact terms with dense semantic retrieval for paraphrases and vague recollection.",
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
id: "concepts/canonical-memory-cards",
|
|
289
|
+
type: "concept",
|
|
290
|
+
title: "Canonical Memory Cards",
|
|
291
|
+
tags: ["zettelkasten", "memory"],
|
|
292
|
+
body: "A reviewed canonical card should be returned before raw observations, with source evidence preserved behind the card.",
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
id: "concepts/zettelkasten",
|
|
296
|
+
type: "concept",
|
|
297
|
+
title: "Zettelkasten",
|
|
298
|
+
aliases: ["card box method", "卡片盒笔记法"],
|
|
299
|
+
body: "Zettelkasten organizes atomic permanent notes and meaningful links. It is a knowledge model, not a search ranking algorithm.",
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
id: "concepts/obsidian-graph-view",
|
|
303
|
+
type: "concept",
|
|
304
|
+
title: "Obsidian Graph View",
|
|
305
|
+
body: "Obsidian Graph View helps people inspect links, clusters, and orphan notes. It visualizes existing links but does not improve machine search relevance by itself.",
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
id: "concepts/wiki-reindex",
|
|
309
|
+
type: "concept",
|
|
310
|
+
title: "Wiki Reindexing",
|
|
311
|
+
aliases: ["wiki_reindex"],
|
|
312
|
+
body: "Reindexing rescans valid Markdown, refreshes lexical state, embeds stale chunks, removes deleted pages, and keeps the last usable index if rebuilding fails.",
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
id: "concepts/node-runtime-policy",
|
|
316
|
+
type: "concept",
|
|
317
|
+
title: "Node Runtime Policy",
|
|
318
|
+
body: "The QMD-backed major requires Node.js 22 or newer. Users requiring Node.js 18 remain on the previous package major.",
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
id: "concepts/conflict-preservation",
|
|
322
|
+
type: "concept",
|
|
323
|
+
title: "Memory Conflict Preservation",
|
|
324
|
+
body: "Conflicting claims remain stored with their evidence. Recall shows both and asks the user which scope or claim applies instead of silently choosing.",
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
id: "concepts/retrieval-feedback",
|
|
328
|
+
type: "concept",
|
|
329
|
+
title: "Retrieval Feedback",
|
|
330
|
+
body: "Explicit corrections are strong signals. Opened, cited, and shown-only events are weaker, bounded, decaying signals and never rewrite facts.",
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
id: "concepts/query-expansion",
|
|
334
|
+
type: "concept",
|
|
335
|
+
title: "Query Expansion",
|
|
336
|
+
body: "Query expansion may improve broad questions but can drift. Adaptive retrieval escalates only uncertain results while exact identifiers bypass expansion.",
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
id: "concepts/validated-index-mirror",
|
|
340
|
+
type: "concept",
|
|
341
|
+
title: "Validated Index Mirror",
|
|
342
|
+
body: "QMD indexes a generated mirror containing only pages accepted by the shared Markdown parser, so malformed pages cannot influence candidate generation.",
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
id: "syntheses/second-brain-retrieval",
|
|
346
|
+
type: "synthesis",
|
|
347
|
+
title: "Second-Brain Retrieval Architecture",
|
|
348
|
+
body: "The strongest second-brain design combines QMD candidate retrieval with canonical-card prioritization, source evidence, contradiction assembly, bounded feedback, and measured evaluation.",
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
id: "analyses/qmd-adoption-decision",
|
|
352
|
+
type: "analysis",
|
|
353
|
+
title: "QMD Adoption Decision",
|
|
354
|
+
body: "The project chose QMD as the first-class retrieval engine rather than rebuilding BM25, vector search, RRF, and local reranking.",
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
id: "sources/qmd-2-5-3-release",
|
|
358
|
+
type: "source",
|
|
359
|
+
title: "QMD 2.5.3 Release Evidence",
|
|
360
|
+
body: "The published QMD 2.5.3 package requires Node.js 22, exposes a TypeScript SDK, and supports model-free searchLex plus model-backed vector and reranking calls.",
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
id: "sources/vault-audit",
|
|
364
|
+
type: "source",
|
|
365
|
+
title: "Vault Retrieval Audit",
|
|
366
|
+
body: "The audited vault had hundreds of source observations, few canonical concepts, almost no retrieval metadata, and no active embedding sidecar.",
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
id: "sources/user-card-first-decision",
|
|
370
|
+
type: "source",
|
|
371
|
+
title: "Card-First User Decision",
|
|
372
|
+
body: "The user selected canonical atomic cards first, with observations retained as evidence rather than mixed equally in recall.",
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
id: "sources/reindex-failure-recovery",
|
|
376
|
+
type: "source",
|
|
377
|
+
title: "Reindex Recovery Requirement",
|
|
378
|
+
body: "A failed full rebuild must preserve the previous closed database artifact and report stale derived state instead of deleting the usable index.",
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
id: "sources/node-22-decision",
|
|
382
|
+
type: "source",
|
|
383
|
+
title: "Node 22 Major-Version Decision",
|
|
384
|
+
body: "The user approved a clean major-version break to Node.js 22 so QMD can be a first-class dependency.",
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
id: "sources/old-conflicting-claim",
|
|
388
|
+
type: "source",
|
|
389
|
+
title: "Older Conflicting Claim",
|
|
390
|
+
body: "An older note says raw observations should always outrank synthesized cards because they are closer to original evidence.",
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
id: "sources/new-conflicting-claim",
|
|
394
|
+
type: "source",
|
|
395
|
+
title: "Newer Conflicting Claim",
|
|
396
|
+
body: "A newer reviewed decision says canonical cards should rank first while raw observations remain attached evidence.",
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
id: "sources/feedback-evaluation",
|
|
400
|
+
type: "source",
|
|
401
|
+
title: "Feedback Evaluation Evidence",
|
|
402
|
+
body: "Click and open behavior is position-biased, so implicit engagement must remain a tiny supplementary signal rather than factual ground truth.",
|
|
403
|
+
},
|
|
404
|
+
];
|
|
405
|
+
|
|
406
|
+
interface QueryGroup {
|
|
407
|
+
key: string;
|
|
408
|
+
category: BenchmarkQuery["category"];
|
|
409
|
+
split: BenchmarkSplit;
|
|
410
|
+
texts: [string, string, string, string, string];
|
|
411
|
+
judgments: BenchmarkJudgment[];
|
|
412
|
+
autoExpectation?: AutoExpectation;
|
|
413
|
+
expectedConflicts?: string[];
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const canonical = (pageId: string, grade: 1 | 2 | 3 = 3): BenchmarkJudgment => ({
|
|
417
|
+
pageId,
|
|
418
|
+
grade,
|
|
419
|
+
role: "canonical",
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
const evidence = (pageId: string, grade: 1 | 2 | 3 = 2): BenchmarkJudgment => ({
|
|
423
|
+
pageId,
|
|
424
|
+
grade,
|
|
425
|
+
role: "evidence",
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
const groups: QueryGroup[] = [
|
|
429
|
+
{
|
|
430
|
+
key: "qmd-exact",
|
|
431
|
+
category: "exact_lookup",
|
|
432
|
+
split: "train",
|
|
433
|
+
texts: [
|
|
434
|
+
"QMD",
|
|
435
|
+
"Query Markup Documents",
|
|
436
|
+
"QMD Markdown search engine",
|
|
437
|
+
"find the QMD note",
|
|
438
|
+
"QMD BM25 vector reranker",
|
|
439
|
+
],
|
|
440
|
+
judgments: [canonical("entities/qmd"), evidence("sources/qmd-2-5-3-release")],
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
key: "qmd-entity",
|
|
444
|
+
category: "entity_alias",
|
|
445
|
+
split: "train",
|
|
446
|
+
texts: [
|
|
447
|
+
"what tool is abbreviated QMD",
|
|
448
|
+
"the local document search tool",
|
|
449
|
+
"which entity provides local Markdown retrieval",
|
|
450
|
+
"QMD full name",
|
|
451
|
+
"on-device search for Markdown notes",
|
|
452
|
+
],
|
|
453
|
+
judgments: [canonical("entities/qmd"), canonical("analyses/qmd-adoption-decision", 2)],
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
key: "hybrid-paraphrase",
|
|
457
|
+
category: "paraphrase",
|
|
458
|
+
split: "heldout",
|
|
459
|
+
texts: [
|
|
460
|
+
"combine exact words with meaning based search",
|
|
461
|
+
"find notes when I remember different wording",
|
|
462
|
+
"keyword plus semantic document lookup",
|
|
463
|
+
"search using both literal terms and concepts",
|
|
464
|
+
"mix sparse and dense retrieval",
|
|
465
|
+
],
|
|
466
|
+
judgments: [canonical("concepts/hybrid-retrieval"), canonical("concepts/reciprocal-rank-fusion", 1)],
|
|
467
|
+
},
|
|
468
|
+
{
|
|
469
|
+
key: "cards-vague",
|
|
470
|
+
category: "vague_recollection",
|
|
471
|
+
split: "train",
|
|
472
|
+
texts: [
|
|
473
|
+
"the decision about useful memory before noisy notes",
|
|
474
|
+
"what should the AI remember first",
|
|
475
|
+
"reviewed conclusion with proof behind it",
|
|
476
|
+
"stop raw observations drowning good knowledge",
|
|
477
|
+
"card first memory organization",
|
|
478
|
+
],
|
|
479
|
+
judgments: [canonical("concepts/canonical-memory-cards"), evidence("sources/user-card-first-decision")],
|
|
480
|
+
},
|
|
481
|
+
{
|
|
482
|
+
key: "zettelkasten-concept",
|
|
483
|
+
category: "conceptual",
|
|
484
|
+
split: "train",
|
|
485
|
+
texts: [
|
|
486
|
+
"what is Zettelkasten",
|
|
487
|
+
"is Zettelkasten a search algorithm",
|
|
488
|
+
"atomic permanent notes and meaningful links",
|
|
489
|
+
"什么是卡片盒笔记法",
|
|
490
|
+
"zettelkasten 是搜索算法吗",
|
|
491
|
+
],
|
|
492
|
+
judgments: [canonical("concepts/zettelkasten")],
|
|
493
|
+
},
|
|
494
|
+
{
|
|
495
|
+
key: "obsidian-graph",
|
|
496
|
+
category: "graph_scope",
|
|
497
|
+
split: "heldout",
|
|
498
|
+
texts: [
|
|
499
|
+
"does Obsidian graph improve search ranking",
|
|
500
|
+
"what is graph view useful for",
|
|
501
|
+
"find orphan notes visually",
|
|
502
|
+
"can the Obsidian graph replace retrieval",
|
|
503
|
+
"human link visualization versus machine relevance",
|
|
504
|
+
],
|
|
505
|
+
judgments: [canonical("concepts/obsidian-graph-view")],
|
|
506
|
+
},
|
|
507
|
+
{
|
|
508
|
+
key: "reindex-evidence",
|
|
509
|
+
category: "evidence_request",
|
|
510
|
+
split: "train",
|
|
511
|
+
texts: [
|
|
512
|
+
"how do I rebuild a stale wiki search index",
|
|
513
|
+
"what evidence says failed reindex keeps the old database",
|
|
514
|
+
"refresh lexical documents and stale vectors",
|
|
515
|
+
"remove deleted notes from search",
|
|
516
|
+
"wiki_reindex recovery requirement",
|
|
517
|
+
],
|
|
518
|
+
judgments: [canonical("concepts/wiki-reindex"), evidence("sources/reindex-failure-recovery")],
|
|
519
|
+
},
|
|
520
|
+
{
|
|
521
|
+
key: "node-temporal",
|
|
522
|
+
category: "temporal",
|
|
523
|
+
split: "train",
|
|
524
|
+
texts: [
|
|
525
|
+
"which Node version does the next major require",
|
|
526
|
+
"can Node 18 use the QMD major",
|
|
527
|
+
"current runtime decision for QMD",
|
|
528
|
+
"why did the project move to Node 22",
|
|
529
|
+
"published QMD package Node requirement",
|
|
530
|
+
],
|
|
531
|
+
judgments: [canonical("concepts/node-runtime-policy"), evidence("sources/node-22-decision")],
|
|
532
|
+
},
|
|
533
|
+
{
|
|
534
|
+
key: "memory-conflict",
|
|
535
|
+
category: "contradiction",
|
|
536
|
+
split: "heldout",
|
|
537
|
+
texts: [
|
|
538
|
+
"what happens when two memories disagree",
|
|
539
|
+
"raw observations or canonical cards should rank first",
|
|
540
|
+
"show both conflicting claims",
|
|
541
|
+
"which note superseded the older ranking claim",
|
|
542
|
+
"do not silently resolve contradictory memory",
|
|
543
|
+
],
|
|
544
|
+
judgments: [
|
|
545
|
+
canonical("concepts/conflict-preservation"),
|
|
546
|
+
evidence("sources/old-conflicting-claim", 3),
|
|
547
|
+
evidence("sources/new-conflicting-claim", 3),
|
|
548
|
+
],
|
|
549
|
+
expectedConflicts: ["sources/old-conflicting-claim", "sources/new-conflicting-claim"],
|
|
550
|
+
},
|
|
551
|
+
{
|
|
552
|
+
key: "feedback-conclusion",
|
|
553
|
+
category: "conclusion",
|
|
554
|
+
split: "train",
|
|
555
|
+
texts: [
|
|
556
|
+
"what did we conclude about retrieval feedback",
|
|
557
|
+
"should ignored search results change facts",
|
|
558
|
+
"how strong is an opened-result signal",
|
|
559
|
+
"explicit correction versus implicit engagement",
|
|
560
|
+
"position bias in second-brain feedback",
|
|
561
|
+
],
|
|
562
|
+
judgments: [canonical("concepts/retrieval-feedback"), evidence("sources/feedback-evaluation")],
|
|
563
|
+
},
|
|
564
|
+
{
|
|
565
|
+
key: "architecture-synthesis",
|
|
566
|
+
category: "synthesis",
|
|
567
|
+
split: "train",
|
|
568
|
+
texts: [
|
|
569
|
+
"best architecture for an AI second brain",
|
|
570
|
+
"combine QMD with canonical evidence memory",
|
|
571
|
+
"full retrieval design summary",
|
|
572
|
+
"why QMD alone is not enough",
|
|
573
|
+
"candidate search plus wiki memory semantics",
|
|
574
|
+
],
|
|
575
|
+
judgments: [canonical("syntheses/second-brain-retrieval"), canonical("analyses/qmd-adoption-decision", 2)],
|
|
576
|
+
},
|
|
577
|
+
{
|
|
578
|
+
key: "unrelated-negative",
|
|
579
|
+
category: "negative",
|
|
580
|
+
split: "train",
|
|
581
|
+
texts: [
|
|
582
|
+
"Flipkart casual mens wear",
|
|
583
|
+
"Razorpay webhook endpoint",
|
|
584
|
+
"hotel trial balance audit",
|
|
585
|
+
"exercise deletion in an admin panel",
|
|
586
|
+
"sales register round off convention",
|
|
587
|
+
],
|
|
588
|
+
judgments: [],
|
|
589
|
+
autoExpectation: "none",
|
|
590
|
+
},
|
|
591
|
+
];
|
|
592
|
+
|
|
593
|
+
export const benchmarkQueries: BenchmarkQuery[] = groups.flatMap((group) =>
|
|
594
|
+
group.texts.map((text, index) => ({
|
|
595
|
+
id: `${group.key}-${index + 1}`,
|
|
596
|
+
text,
|
|
597
|
+
category: group.category,
|
|
598
|
+
split: group.split,
|
|
599
|
+
judgments: group.judgments.map((judgment) => ({ ...judgment })),
|
|
600
|
+
autoExpectation: group.autoExpectation ?? "hit",
|
|
601
|
+
expectedConflicts: group.expectedConflicts ? [...group.expectedConflicts] : undefined,
|
|
602
|
+
})),
|
|
603
|
+
);
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
- [ ] **Step 4: Run the fixture-contract test**
|
|
607
|
+
|
|
608
|
+
Run:
|
|
609
|
+
|
|
610
|
+
```bash
|
|
611
|
+
pnpm exec vitest run test/retrieval-benchmark-fixture.test.ts --reporter=verbose
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
Expected: 4 tests PASS; query count is 60, split is 45/15, and no privacy pattern appears.
|
|
615
|
+
|
|
616
|
+
- [ ] **Step 5: Commit the fixture**
|
|
617
|
+
|
|
618
|
+
```bash
|
|
619
|
+
git add test/fixtures/retrieval-benchmark/fixture.ts test/retrieval-benchmark-fixture.test.ts
|
|
620
|
+
git commit -m "test: add retrieval benchmark fixture"
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
---
|
|
624
|
+
|
|
625
|
+
### Task 3: Implement deterministic retrieval metrics
|
|
626
|
+
|
|
627
|
+
**Files:**
|
|
628
|
+
- Create: `test/helpers/retrieval-metrics.ts`
|
|
629
|
+
- Create: `test/retrieval-metrics.test.ts`
|
|
630
|
+
|
|
631
|
+
- [ ] **Step 1: Write failing unit tests for ranking metrics**
|
|
632
|
+
|
|
633
|
+
Create `test/retrieval-metrics.test.ts`:
|
|
634
|
+
|
|
635
|
+
```ts
|
|
636
|
+
import { describe, expect, it } from "vitest";
|
|
637
|
+
import type { BenchmarkQuery } from "./fixtures/retrieval-benchmark/fixture.js";
|
|
638
|
+
import {
|
|
639
|
+
evaluateBenchmark,
|
|
640
|
+
ndcgAt,
|
|
641
|
+
reciprocalRank,
|
|
642
|
+
recallAt,
|
|
643
|
+
type BenchmarkRun,
|
|
644
|
+
} from "./helpers/retrieval-metrics.js";
|
|
645
|
+
|
|
646
|
+
const queries: BenchmarkQuery[] = [
|
|
647
|
+
{
|
|
648
|
+
id: "ranked",
|
|
649
|
+
text: "ranked query",
|
|
650
|
+
category: "contradiction",
|
|
651
|
+
split: "train",
|
|
652
|
+
judgments: [
|
|
653
|
+
{ pageId: "cards/a", grade: 3, role: "canonical" },
|
|
654
|
+
{ pageId: "sources/b", grade: 2, role: "evidence" },
|
|
655
|
+
],
|
|
656
|
+
autoExpectation: "hit",
|
|
657
|
+
expectedConflicts: ["cards/a", "sources/b"],
|
|
658
|
+
},
|
|
659
|
+
{
|
|
660
|
+
id: "negative",
|
|
661
|
+
text: "unrelated query",
|
|
662
|
+
category: "negative",
|
|
663
|
+
split: "heldout",
|
|
664
|
+
judgments: [],
|
|
665
|
+
autoExpectation: "none",
|
|
666
|
+
},
|
|
667
|
+
];
|
|
668
|
+
|
|
669
|
+
const runs: BenchmarkRun[] = [
|
|
670
|
+
{ queryId: "ranked", rankedPageIds: ["sources/b", "cards/a", "other/x"], autoPageIds: ["sources/b"] },
|
|
671
|
+
{ queryId: "negative", rankedPageIds: ["other/x"], autoPageIds: ["other/x"] },
|
|
672
|
+
];
|
|
673
|
+
|
|
674
|
+
describe("retrieval metrics", () => {
|
|
675
|
+
it("computes recall and reciprocal rank", () => {
|
|
676
|
+
expect(recallAt(["x", "a", "b"], new Set(["a", "b"]), 2)).toBe(0.5);
|
|
677
|
+
expect(reciprocalRank(["x", "a", "b"], new Set(["a", "b"]))).toBe(0.5);
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
it("computes graded nDCG independent of raw retrieval scores", () => {
|
|
681
|
+
const grades = new Map([
|
|
682
|
+
["cards/a", 3],
|
|
683
|
+
["sources/b", 2],
|
|
684
|
+
]);
|
|
685
|
+
expect(ndcgAt(["cards/a", "sources/b"], grades, 2)).toBe(1);
|
|
686
|
+
expect(ndcgAt(["sources/b", "cards/a"], grades, 2)).toBeGreaterThan(0.8);
|
|
687
|
+
expect(ndcgAt(["sources/b", "cards/a"], grades, 2)).toBeLessThan(1);
|
|
688
|
+
expect(ndcgAt(["sources/b", "cards/a"], grades, 2)).toBeCloseTo(0.834, 3);
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
it("separates relevance, evidence, contradiction, and auto false-positive measures", () => {
|
|
692
|
+
const report = evaluateBenchmark(queries, runs);
|
|
693
|
+
expect(report.all.candidateRecall20).toBe(1);
|
|
694
|
+
expect(report.all.mrr).toBe(1);
|
|
695
|
+
expect(report.all.canonicalAt3).toBe(1);
|
|
696
|
+
expect(report.all.evidenceRecall20).toBe(1);
|
|
697
|
+
expect(report.all.contradictionCoverage).toBe(1);
|
|
698
|
+
expect(report.all.autoFalsePositiveRate).toBe(1);
|
|
699
|
+
expect(report.train.queryCount).toBe(1);
|
|
700
|
+
expect(report.heldout.queryCount).toBe(1);
|
|
701
|
+
});
|
|
702
|
+
});
|
|
703
|
+
```
|
|
704
|
+
|
|
705
|
+
- [ ] **Step 2: Run the metric test and verify the helper is missing**
|
|
706
|
+
|
|
707
|
+
Run:
|
|
708
|
+
|
|
709
|
+
```bash
|
|
710
|
+
pnpm exec vitest run test/retrieval-metrics.test.ts --reporter=verbose
|
|
711
|
+
```
|
|
712
|
+
|
|
713
|
+
Expected: FAIL with a module-resolution error for `helpers/retrieval-metrics.js`.
|
|
714
|
+
|
|
715
|
+
- [ ] **Step 3: Implement the pure metric helper**
|
|
716
|
+
|
|
717
|
+
Create `test/helpers/retrieval-metrics.ts`:
|
|
718
|
+
|
|
719
|
+
```ts
|
|
720
|
+
import type {
|
|
721
|
+
BenchmarkJudgment,
|
|
722
|
+
BenchmarkQuery,
|
|
723
|
+
BenchmarkSplit,
|
|
724
|
+
} from "../fixtures/retrieval-benchmark/fixture.js";
|
|
725
|
+
|
|
726
|
+
export interface BenchmarkRun {
|
|
727
|
+
queryId: string;
|
|
728
|
+
rankedPageIds: string[];
|
|
729
|
+
autoPageIds: string[];
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
export interface RetrievalMetrics {
|
|
733
|
+
queryCount: number;
|
|
734
|
+
judgedQueryCount: number;
|
|
735
|
+
candidateRecall20: number;
|
|
736
|
+
mrr: number;
|
|
737
|
+
ndcg5: number;
|
|
738
|
+
ndcg10: number;
|
|
739
|
+
canonicalAt3: number;
|
|
740
|
+
evidenceRecall20: number;
|
|
741
|
+
contradictionCoverage: number;
|
|
742
|
+
autoFalsePositiveRate: number;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
export interface RetrievalMetricReport {
|
|
746
|
+
all: RetrievalMetrics;
|
|
747
|
+
train: RetrievalMetrics;
|
|
748
|
+
heldout: RetrievalMetrics;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function round(value: number): number {
|
|
752
|
+
return Number(value.toFixed(6));
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function mean(values: number[]): number {
|
|
756
|
+
return values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
export function recallAt(rankedIds: string[], relevantIds: Set<string>, k: number): number {
|
|
760
|
+
if (relevantIds.size === 0) return 0;
|
|
761
|
+
const found = new Set(rankedIds.slice(0, k).filter((id) => relevantIds.has(id)));
|
|
762
|
+
return found.size / relevantIds.size;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
export function reciprocalRank(rankedIds: string[], relevantIds: Set<string>): number {
|
|
766
|
+
const index = rankedIds.findIndex((id) => relevantIds.has(id));
|
|
767
|
+
return index < 0 ? 0 : 1 / (index + 1);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function dcg(grades: number[]): number {
|
|
771
|
+
return grades.reduce((sum, grade, index) => sum + (2 ** grade - 1) / Math.log2(index + 2), 0);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
export function ndcgAt(rankedIds: string[], gradesById: Map<string, number>, k: number): number {
|
|
775
|
+
const actual = rankedIds.slice(0, k).map((id) => gradesById.get(id) ?? 0);
|
|
776
|
+
const ideal = [...gradesById.values()].sort((a, b) => b - a).slice(0, k);
|
|
777
|
+
const idealDcg = dcg(ideal);
|
|
778
|
+
return idealDcg === 0 ? 0 : dcg(actual) / idealDcg;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function idsForRole(judgments: BenchmarkJudgment[], role?: BenchmarkJudgment["role"]): Set<string> {
|
|
782
|
+
return new Set(
|
|
783
|
+
judgments
|
|
784
|
+
.filter((judgment) => judgment.grade > 0 && (role === undefined || judgment.role === role))
|
|
785
|
+
.map((judgment) => judgment.pageId),
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function evaluateSlice(queries: BenchmarkQuery[], runById: Map<string, BenchmarkRun>): RetrievalMetrics {
|
|
790
|
+
const judged = queries.filter((query) => query.judgments.length > 0);
|
|
791
|
+
const canonical = queries.filter((query) => idsForRole(query.judgments, "canonical").size > 0);
|
|
792
|
+
const evidence = queries.filter((query) => idsForRole(query.judgments, "evidence").size > 0);
|
|
793
|
+
const conflicts = queries.filter((query) => (query.expectedConflicts?.length ?? 0) > 0);
|
|
794
|
+
const negatives = queries.filter((query) => query.autoExpectation === "none");
|
|
795
|
+
|
|
796
|
+
const rankedFor = (query: BenchmarkQuery) => runById.get(query.id)?.rankedPageIds ?? [];
|
|
797
|
+
const autoFor = (query: BenchmarkQuery) => runById.get(query.id)?.autoPageIds ?? [];
|
|
798
|
+
|
|
799
|
+
return {
|
|
800
|
+
queryCount: queries.length,
|
|
801
|
+
judgedQueryCount: judged.length,
|
|
802
|
+
candidateRecall20: round(
|
|
803
|
+
mean(judged.map((query) => recallAt(rankedFor(query), idsForRole(query.judgments), 20))),
|
|
804
|
+
),
|
|
805
|
+
mrr: round(
|
|
806
|
+
mean(judged.map((query) => reciprocalRank(rankedFor(query), idsForRole(query.judgments)))),
|
|
807
|
+
),
|
|
808
|
+
ndcg5: round(
|
|
809
|
+
mean(
|
|
810
|
+
judged.map((query) =>
|
|
811
|
+
ndcgAt(
|
|
812
|
+
rankedFor(query),
|
|
813
|
+
new Map(query.judgments.map((judgment) => [judgment.pageId, judgment.grade])),
|
|
814
|
+
5,
|
|
815
|
+
),
|
|
816
|
+
),
|
|
817
|
+
),
|
|
818
|
+
),
|
|
819
|
+
ndcg10: round(
|
|
820
|
+
mean(
|
|
821
|
+
judged.map((query) =>
|
|
822
|
+
ndcgAt(
|
|
823
|
+
rankedFor(query),
|
|
824
|
+
new Map(query.judgments.map((judgment) => [judgment.pageId, judgment.grade])),
|
|
825
|
+
10,
|
|
826
|
+
),
|
|
827
|
+
),
|
|
828
|
+
),
|
|
829
|
+
),
|
|
830
|
+
canonicalAt3: round(
|
|
831
|
+
mean(
|
|
832
|
+
canonical.map((query) => {
|
|
833
|
+
const relevant = idsForRole(query.judgments, "canonical");
|
|
834
|
+
return rankedFor(query).slice(0, 3).some((id) => relevant.has(id)) ? 1 : 0;
|
|
835
|
+
}),
|
|
836
|
+
),
|
|
837
|
+
),
|
|
838
|
+
evidenceRecall20: round(
|
|
839
|
+
mean(
|
|
840
|
+
evidence.map((query) => recallAt(rankedFor(query), idsForRole(query.judgments, "evidence"), 20)),
|
|
841
|
+
),
|
|
842
|
+
),
|
|
843
|
+
contradictionCoverage: round(
|
|
844
|
+
mean(
|
|
845
|
+
conflicts.map((query) => {
|
|
846
|
+
const found = new Set(rankedFor(query).slice(0, 20));
|
|
847
|
+
return query.expectedConflicts!.every((id) => found.has(id)) ? 1 : 0;
|
|
848
|
+
}),
|
|
849
|
+
),
|
|
850
|
+
),
|
|
851
|
+
autoFalsePositiveRate: round(
|
|
852
|
+
mean(negatives.map((query) => (autoFor(query).length > 0 ? 1 : 0))),
|
|
853
|
+
),
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
export function evaluateBenchmark(
|
|
858
|
+
queries: BenchmarkQuery[],
|
|
859
|
+
runs: BenchmarkRun[],
|
|
860
|
+
): RetrievalMetricReport {
|
|
861
|
+
const runById = new Map(runs.map((run) => [run.queryId, run]));
|
|
862
|
+
const bySplit = (split: BenchmarkSplit) => queries.filter((query) => query.split === split);
|
|
863
|
+
return {
|
|
864
|
+
all: evaluateSlice(queries, runById),
|
|
865
|
+
train: evaluateSlice(bySplit("train"), runById),
|
|
866
|
+
heldout: evaluateSlice(bySplit("heldout"), runById),
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
```
|
|
870
|
+
|
|
871
|
+
- [ ] **Step 4: Run fixture and metric tests**
|
|
872
|
+
|
|
873
|
+
Run:
|
|
874
|
+
|
|
875
|
+
```bash
|
|
876
|
+
pnpm exec vitest run test/retrieval-benchmark-fixture.test.ts test/retrieval-metrics.test.ts --reporter=verbose
|
|
877
|
+
```
|
|
878
|
+
|
|
879
|
+
Expected: 7 tests PASS.
|
|
880
|
+
|
|
881
|
+
- [ ] **Step 5: Commit the metric engine**
|
|
882
|
+
|
|
883
|
+
```bash
|
|
884
|
+
git add test/helpers/retrieval-metrics.ts test/retrieval-metrics.test.ts
|
|
885
|
+
git commit -m "test: add retrieval quality metrics"
|
|
886
|
+
```
|
|
887
|
+
|
|
888
|
+
---
|
|
889
|
+
|
|
890
|
+
### Task 4: Record the current heuristic recall baseline
|
|
891
|
+
|
|
892
|
+
**Files:**
|
|
893
|
+
- Create: `test/retrieval-benchmark.test.ts`
|
|
894
|
+
- Create: `scripts/update-retrieval-baseline.mjs`
|
|
895
|
+
- Create: `docs/superpowers/benchmarks/phase-1-current-baseline.json` (generated)
|
|
896
|
+
- Modify: `package.json`
|
|
897
|
+
|
|
898
|
+
- [ ] **Step 1: Create the benchmark runner test**
|
|
899
|
+
|
|
900
|
+
Create `test/retrieval-benchmark.test.ts`:
|
|
901
|
+
|
|
902
|
+
```ts
|
|
903
|
+
import {
|
|
904
|
+
existsSync,
|
|
905
|
+
mkdirSync,
|
|
906
|
+
mkdtempSync,
|
|
907
|
+
readFileSync,
|
|
908
|
+
rmSync,
|
|
909
|
+
writeFileSync,
|
|
910
|
+
} from "node:fs";
|
|
911
|
+
import { tmpdir } from "node:os";
|
|
912
|
+
import { dirname, join } from "node:path";
|
|
913
|
+
import { afterAll, describe, expect, it } from "vitest";
|
|
914
|
+
import { rebuildMetadata } from "../extensions/llm-wiki/lib/metadata.js";
|
|
915
|
+
import { searchWiki } from "../extensions/llm-wiki/lib/recall.js";
|
|
916
|
+
import { ensureVaultStructure, getVaultPaths } from "../extensions/llm-wiki/lib/utils.js";
|
|
917
|
+
import {
|
|
918
|
+
BENCHMARK_VERSION,
|
|
919
|
+
benchmarkPages,
|
|
920
|
+
benchmarkQueries,
|
|
921
|
+
type BenchmarkPage,
|
|
922
|
+
} from "./fixtures/retrieval-benchmark/fixture.js";
|
|
923
|
+
import { rootDir } from "./helpers.js";
|
|
924
|
+
import { evaluateBenchmark, type BenchmarkRun } from "./helpers/retrieval-metrics.js";
|
|
925
|
+
|
|
926
|
+
const baselinePath = join(
|
|
927
|
+
rootDir,
|
|
928
|
+
"docs",
|
|
929
|
+
"superpowers",
|
|
930
|
+
"benchmarks",
|
|
931
|
+
"phase-1-current-baseline.json",
|
|
932
|
+
);
|
|
933
|
+
const tempRoot = mkdtempSync(join(tmpdir(), "pi-llm-wiki-retrieval-"));
|
|
934
|
+
|
|
935
|
+
afterAll(() => rmSync(tempRoot, { recursive: true, force: true }));
|
|
936
|
+
|
|
937
|
+
function renderPage(page: BenchmarkPage): string {
|
|
938
|
+
const metadata = [
|
|
939
|
+
"---",
|
|
940
|
+
`type: ${page.type}`,
|
|
941
|
+
`title: ${JSON.stringify(page.title)}`,
|
|
942
|
+
`status: ${page.status ?? "stable"}`,
|
|
943
|
+
...(page.aliases ? [`aliases: ${JSON.stringify(page.aliases)}`] : []),
|
|
944
|
+
...(page.tags ? [`tags: ${JSON.stringify(page.tags)}`] : []),
|
|
945
|
+
"---",
|
|
946
|
+
"",
|
|
947
|
+
];
|
|
948
|
+
return `${metadata.join("\n")}${page.body}\n`;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function createBenchmarkVault(): ReturnType<typeof getVaultPaths> {
|
|
952
|
+
const paths = getVaultPaths(tempRoot);
|
|
953
|
+
ensureVaultStructure(paths);
|
|
954
|
+
writeFileSync(
|
|
955
|
+
join(paths.dotWiki, "config.json"),
|
|
956
|
+
`${JSON.stringify({ name: "Retrieval Benchmark", knowledge_format: "legacy" }, null, 2)}\n`,
|
|
957
|
+
);
|
|
958
|
+
for (const page of benchmarkPages) {
|
|
959
|
+
const path = join(paths.wiki, `${page.id}.md`);
|
|
960
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
961
|
+
writeFileSync(path, renderPage(page));
|
|
962
|
+
}
|
|
963
|
+
const rebuilt = rebuildMetadata(paths);
|
|
964
|
+
expect(rebuilt.ok, JSON.stringify(rebuilt.diagnostics, null, 2)).toBe(true);
|
|
965
|
+
return paths;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
function currentPackageContract(): { node: string; qmd: string } {
|
|
969
|
+
const pkg = JSON.parse(readFileSync(join(rootDir, "package.json"), "utf8")) as {
|
|
970
|
+
engines: { node: string };
|
|
971
|
+
dependencies: Record<string, string>;
|
|
972
|
+
};
|
|
973
|
+
return { node: pkg.engines.node, qmd: pkg.dependencies["@tobilu/qmd"] };
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
describe("current heuristic retrieval benchmark", () => {
|
|
977
|
+
it("matches the committed deterministic Phase 1 baseline", () => {
|
|
978
|
+
const paths = createBenchmarkVault();
|
|
979
|
+
const runs: BenchmarkRun[] = benchmarkQueries.map((query) => ({
|
|
980
|
+
queryId: query.id,
|
|
981
|
+
rankedPageIds: searchWiki(paths, query.text, 20, 0).map((result) => result.id),
|
|
982
|
+
autoPageIds: searchWiki(paths, query.text, 3, 5).map((result) => result.id),
|
|
983
|
+
}));
|
|
984
|
+
const report = {
|
|
985
|
+
schema: 1,
|
|
986
|
+
fixtureVersion: BENCHMARK_VERSION,
|
|
987
|
+
engine: "current-heuristic",
|
|
988
|
+
productionRecallChanged: false,
|
|
989
|
+
packageContract: currentPackageContract(),
|
|
990
|
+
queryCounts: {
|
|
991
|
+
all: benchmarkQueries.length,
|
|
992
|
+
train: benchmarkQueries.filter((query) => query.split === "train").length,
|
|
993
|
+
heldout: benchmarkQueries.filter((query) => query.split === "heldout").length,
|
|
994
|
+
},
|
|
995
|
+
metrics: evaluateBenchmark(benchmarkQueries, runs),
|
|
996
|
+
};
|
|
997
|
+
|
|
998
|
+
if (process.env.UPDATE_RETRIEVAL_BASELINE === "1") {
|
|
999
|
+
mkdirSync(dirname(baselinePath), { recursive: true });
|
|
1000
|
+
writeFileSync(baselinePath, `${JSON.stringify(report, null, 2)}\n`);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
expect(existsSync(baselinePath), `Run pnpm benchmark:retrieval:update to create ${baselinePath}`).toBe(
|
|
1004
|
+
true,
|
|
1005
|
+
);
|
|
1006
|
+
expect(JSON.parse(readFileSync(baselinePath, "utf8"))).toEqual(report);
|
|
1007
|
+
});
|
|
1008
|
+
});
|
|
1009
|
+
```
|
|
1010
|
+
|
|
1011
|
+
- [ ] **Step 2: Run the benchmark test and verify the baseline is missing**
|
|
1012
|
+
|
|
1013
|
+
Run:
|
|
1014
|
+
|
|
1015
|
+
```bash
|
|
1016
|
+
pnpm exec vitest run test/retrieval-benchmark.test.ts --reporter=verbose
|
|
1017
|
+
```
|
|
1018
|
+
|
|
1019
|
+
Expected: FAIL with `Run pnpm benchmark:retrieval:update` because the committed baseline does not exist yet.
|
|
1020
|
+
|
|
1021
|
+
- [ ] **Step 3: Add the cross-platform baseline update script**
|
|
1022
|
+
|
|
1023
|
+
Create `scripts/update-retrieval-baseline.mjs`:
|
|
1024
|
+
|
|
1025
|
+
```js
|
|
1026
|
+
#!/usr/bin/env node
|
|
1027
|
+
|
|
1028
|
+
import { spawnSync } from "node:child_process";
|
|
1029
|
+
import { join, resolve } from "node:path";
|
|
1030
|
+
|
|
1031
|
+
const root = resolve(import.meta.dirname, "..");
|
|
1032
|
+
const vitest = join(root, "node_modules", "vitest", "vitest.mjs");
|
|
1033
|
+
const result = spawnSync(
|
|
1034
|
+
process.execPath,
|
|
1035
|
+
[vitest, "run", "test/retrieval-benchmark.test.ts", "--reporter=verbose"],
|
|
1036
|
+
{
|
|
1037
|
+
cwd: root,
|
|
1038
|
+
env: { ...process.env, UPDATE_RETRIEVAL_BASELINE: "1" },
|
|
1039
|
+
stdio: "inherit",
|
|
1040
|
+
},
|
|
1041
|
+
);
|
|
1042
|
+
|
|
1043
|
+
process.exit(result.status ?? 1);
|
|
1044
|
+
```
|
|
1045
|
+
|
|
1046
|
+
- [ ] **Step 4: Add benchmark scripts to `package.json`**
|
|
1047
|
+
|
|
1048
|
+
Add these entries to the existing `scripts` object:
|
|
1049
|
+
|
|
1050
|
+
```json
|
|
1051
|
+
"benchmark:retrieval": "vitest run test/retrieval-benchmark.test.ts --reporter=verbose",
|
|
1052
|
+
"benchmark:retrieval:update": "node scripts/update-retrieval-baseline.mjs"
|
|
1053
|
+
```
|
|
1054
|
+
|
|
1055
|
+
- [ ] **Step 5: Generate and inspect the deterministic baseline artifact**
|
|
1056
|
+
|
|
1057
|
+
Run:
|
|
1058
|
+
|
|
1059
|
+
```bash
|
|
1060
|
+
pnpm benchmark:retrieval:update
|
|
1061
|
+
pnpm benchmark:retrieval
|
|
1062
|
+
```
|
|
1063
|
+
|
|
1064
|
+
Expected: both commands PASS and create `docs/superpowers/benchmarks/phase-1-current-baseline.json` with schema 1, 60/45/15 query counts, QMD contract `2.5.3`, and separate all/train/heldout metrics. Do not hand-edit metric values.
|
|
1065
|
+
|
|
1066
|
+
- [ ] **Step 6: Prove the baseline is reproducible**
|
|
1067
|
+
|
|
1068
|
+
Run:
|
|
1069
|
+
|
|
1070
|
+
```bash
|
|
1071
|
+
node -e "const f=require('node:fs').readFileSync('docs/superpowers/benchmarks/phase-1-current-baseline.json');const c=require('node:crypto').createHash('sha256').update(f).digest('hex');console.log(c)"
|
|
1072
|
+
pnpm benchmark:retrieval:update
|
|
1073
|
+
node -e "const f=require('node:fs').readFileSync('docs/superpowers/benchmarks/phase-1-current-baseline.json');const c=require('node:crypto').createHash('sha256').update(f).digest('hex');console.log(c)"
|
|
1074
|
+
```
|
|
1075
|
+
|
|
1076
|
+
Expected: both hashes are identical.
|
|
1077
|
+
|
|
1078
|
+
- [ ] **Step 7: Run all benchmark unit tests**
|
|
1079
|
+
|
|
1080
|
+
Run:
|
|
1081
|
+
|
|
1082
|
+
```bash
|
|
1083
|
+
pnpm exec vitest run test/retrieval-benchmark-fixture.test.ts test/retrieval-metrics.test.ts test/retrieval-benchmark.test.ts --reporter=verbose
|
|
1084
|
+
```
|
|
1085
|
+
|
|
1086
|
+
Expected: 8 tests PASS.
|
|
1087
|
+
|
|
1088
|
+
- [ ] **Step 8: Commit the baseline harness and artifact**
|
|
1089
|
+
|
|
1090
|
+
```bash
|
|
1091
|
+
git add package.json scripts/update-retrieval-baseline.mjs test/retrieval-benchmark.test.ts docs/superpowers/benchmarks/phase-1-current-baseline.json
|
|
1092
|
+
git commit -m "test: record current retrieval baseline"
|
|
1093
|
+
```
|
|
1094
|
+
|
|
1095
|
+
---
|
|
1096
|
+
|
|
1097
|
+
### Task 5: Contract-test QMD's public SDK without activating it
|
|
1098
|
+
|
|
1099
|
+
**Files:**
|
|
1100
|
+
- Create: `test/qmd-contract.test.ts`
|
|
1101
|
+
|
|
1102
|
+
- [ ] **Step 1: Create exact compile-time mode contracts and model-free runtime smoke**
|
|
1103
|
+
|
|
1104
|
+
Create `test/qmd-contract.test.ts`:
|
|
1105
|
+
|
|
1106
|
+
```ts
|
|
1107
|
+
import { homedir, tmpdir } from "node:os";
|
|
1108
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
1109
|
+
import { join } from "node:path";
|
|
1110
|
+
import {
|
|
1111
|
+
createStore,
|
|
1112
|
+
type ExpandedQuery,
|
|
1113
|
+
type QMDStore,
|
|
1114
|
+
type SearchOptions,
|
|
1115
|
+
} from "@tobilu/qmd";
|
|
1116
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
1117
|
+
|
|
1118
|
+
const hybridQueries: ExpandedQuery[] = [
|
|
1119
|
+
{ type: "lex", query: "signed access tokens" },
|
|
1120
|
+
{ type: "vec", query: "how users authenticate" },
|
|
1121
|
+
];
|
|
1122
|
+
|
|
1123
|
+
const modeContracts = {
|
|
1124
|
+
hybrid: {
|
|
1125
|
+
queries: hybridQueries,
|
|
1126
|
+
rerank: false,
|
|
1127
|
+
candidateLimit: 40,
|
|
1128
|
+
limit: 10,
|
|
1129
|
+
explain: true,
|
|
1130
|
+
},
|
|
1131
|
+
adaptiveUncertain: {
|
|
1132
|
+
query: "how users authenticate",
|
|
1133
|
+
intent: "Authentication documentation",
|
|
1134
|
+
rerank: true,
|
|
1135
|
+
candidateLimit: 40,
|
|
1136
|
+
limit: 10,
|
|
1137
|
+
explain: true,
|
|
1138
|
+
},
|
|
1139
|
+
quality: {
|
|
1140
|
+
query: "how users authenticate",
|
|
1141
|
+
intent: "Authentication documentation",
|
|
1142
|
+
rerank: true,
|
|
1143
|
+
candidateLimit: 40,
|
|
1144
|
+
limit: 10,
|
|
1145
|
+
explain: true,
|
|
1146
|
+
},
|
|
1147
|
+
} satisfies Record<string, SearchOptions>;
|
|
1148
|
+
|
|
1149
|
+
const tempRoot = mkdtempSync(join(tmpdir(), "pi-llm-wiki-qmd-contract-"));
|
|
1150
|
+
const docsPath = join(tempRoot, "docs");
|
|
1151
|
+
const dbPath = join(tempRoot, "index.sqlite");
|
|
1152
|
+
let store: QMDStore;
|
|
1153
|
+
|
|
1154
|
+
function modelFiles(): string[] {
|
|
1155
|
+
const modelDir = join(homedir(), ".cache", "qmd", "models");
|
|
1156
|
+
if (!existsSync(modelDir)) return [];
|
|
1157
|
+
return readdirSync(modelDir).sort();
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
beforeAll(async () => {
|
|
1161
|
+
mkdirSync(docsPath, { recursive: true });
|
|
1162
|
+
writeFileSync(join(docsPath, "auth.md"), "# Authentication\n\nUsers authenticate with signed access tokens.\n");
|
|
1163
|
+
writeFileSync(join(docsPath, "cache.md"), "# Cache\n\nCache entries expire after five minutes.\n");
|
|
1164
|
+
store = await createStore({
|
|
1165
|
+
dbPath,
|
|
1166
|
+
config: {
|
|
1167
|
+
global_context: "SDK compatibility fixture",
|
|
1168
|
+
collections: {
|
|
1169
|
+
docs: { path: docsPath, pattern: "**/*.md" },
|
|
1170
|
+
},
|
|
1171
|
+
},
|
|
1172
|
+
});
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1175
|
+
afterAll(async () => {
|
|
1176
|
+
await store?.close();
|
|
1177
|
+
rmSync(tempRoot, { recursive: true, force: true });
|
|
1178
|
+
});
|
|
1179
|
+
|
|
1180
|
+
describe("QMD 2.5.3 SDK contract", () => {
|
|
1181
|
+
it("keeps the four-mode request shapes type-compatible", () => {
|
|
1182
|
+
expect(modeContracts.hybrid.queries).toEqual(hybridQueries);
|
|
1183
|
+
expect(modeContracts.adaptiveUncertain.rerank).toBe(true);
|
|
1184
|
+
expect(modeContracts.quality.candidateLimit).toBe(40);
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
it("updates and performs lexical search without downloading a model", async () => {
|
|
1188
|
+
const beforeModels = modelFiles();
|
|
1189
|
+
const updated = await store.update();
|
|
1190
|
+
expect(updated.collections).toBe(1);
|
|
1191
|
+
expect(updated.indexed).toBe(2);
|
|
1192
|
+
expect(updated.needsEmbedding).toBe(2);
|
|
1193
|
+
|
|
1194
|
+
const results = await store.searchLex("signed access tokens", { collection: "docs", limit: 5 });
|
|
1195
|
+
expect(results.length).toBeGreaterThan(0);
|
|
1196
|
+
expect(results[0].source).toBe("fts");
|
|
1197
|
+
expect(results[0].score).toBeGreaterThan(0);
|
|
1198
|
+
expect(results[0].title).toContain("Authentication");
|
|
1199
|
+
|
|
1200
|
+
const status = await store.getStatus();
|
|
1201
|
+
expect(status.totalDocuments).toBe(2);
|
|
1202
|
+
expect(status.needsEmbedding).toBe(2);
|
|
1203
|
+
expect(modelFiles()).toEqual(beforeModels);
|
|
1204
|
+
});
|
|
1205
|
+
|
|
1206
|
+
it.runIf(process.env.QMD_MODEL_SMOKE === "1")(
|
|
1207
|
+
"embeds, performs vector/hybrid search, expands, and reranks",
|
|
1208
|
+
async () => {
|
|
1209
|
+
const embedded = await store.embed({ force: true, chunkStrategy: "regex" });
|
|
1210
|
+
expect(embedded.docsProcessed).toBe(2);
|
|
1211
|
+
expect(embedded.errors).toBe(0);
|
|
1212
|
+
|
|
1213
|
+
const vector = await store.searchVector("how users log in", { collection: "docs", limit: 5 });
|
|
1214
|
+
expect(vector.length).toBeGreaterThan(0);
|
|
1215
|
+
|
|
1216
|
+
const hybrid = await store.search({ ...modeContracts.hybrid, collections: ["docs"] });
|
|
1217
|
+
expect(hybrid.length).toBeGreaterThan(0);
|
|
1218
|
+
|
|
1219
|
+
const expanded = await store.expandQuery("how users authenticate", {
|
|
1220
|
+
intent: "Authentication documentation",
|
|
1221
|
+
});
|
|
1222
|
+
expect(expanded.length).toBeGreaterThan(0);
|
|
1223
|
+
|
|
1224
|
+
const quality = await store.search({ ...modeContracts.quality, collections: ["docs"] });
|
|
1225
|
+
expect(quality.length).toBeGreaterThan(0);
|
|
1226
|
+
expect(quality[0].score).toBeGreaterThan(0);
|
|
1227
|
+
},
|
|
1228
|
+
1_200_000,
|
|
1229
|
+
);
|
|
1230
|
+
});
|
|
1231
|
+
```
|
|
1232
|
+
|
|
1233
|
+
- [ ] **Step 2: Run the ordinary contract test**
|
|
1234
|
+
|
|
1235
|
+
Run:
|
|
1236
|
+
|
|
1237
|
+
```bash
|
|
1238
|
+
QMD_FORCE_CPU=1 pnpm exec vitest run test/qmd-contract.test.ts --reporter=verbose
|
|
1239
|
+
```
|
|
1240
|
+
|
|
1241
|
+
Windows PowerShell equivalent: `$env:QMD_FORCE_CPU='1'; pnpm exec vitest run test/qmd-contract.test.ts --reporter=verbose`
|
|
1242
|
+
|
|
1243
|
+
Expected: mode-shape and lexical tests PASS; model-backed test is skipped; no QMD model file is created.
|
|
1244
|
+
|
|
1245
|
+
- [ ] **Step 3: Run the full typecheck against QMD's exported declarations**
|
|
1246
|
+
|
|
1247
|
+
Run:
|
|
1248
|
+
|
|
1249
|
+
```bash
|
|
1250
|
+
pnpm typecheck
|
|
1251
|
+
```
|
|
1252
|
+
|
|
1253
|
+
Expected: PASS. A missing or changed QMD method/type fails here before Phase 2 planning.
|
|
1254
|
+
|
|
1255
|
+
- [ ] **Step 4: Commit the SDK contract**
|
|
1256
|
+
|
|
1257
|
+
```bash
|
|
1258
|
+
git add test/qmd-contract.test.ts
|
|
1259
|
+
git commit -m "test: lock QMD SDK compatibility"
|
|
1260
|
+
```
|
|
1261
|
+
|
|
1262
|
+
---
|
|
1263
|
+
|
|
1264
|
+
### Task 6: Add platform CI, model smoke, and operator documentation
|
|
1265
|
+
|
|
1266
|
+
**Files:**
|
|
1267
|
+
- Modify: `.github/workflows/ci.yml`
|
|
1268
|
+
- Create: `.github/workflows/qmd-model-smoke.yml`
|
|
1269
|
+
- Create: `docs/retrieval-benchmark.md`
|
|
1270
|
+
- Create: `docs/qmd-compatibility.md`
|
|
1271
|
+
|
|
1272
|
+
- [ ] **Step 1: Restrict the normal quality matrix to supported Node.js LTS versions**
|
|
1273
|
+
|
|
1274
|
+
In `.github/workflows/ci.yml`, replace:
|
|
1275
|
+
|
|
1276
|
+
```yaml
|
|
1277
|
+
node-version: [20, 22, 23, 24, 25]
|
|
1278
|
+
```
|
|
1279
|
+
|
|
1280
|
+
with:
|
|
1281
|
+
|
|
1282
|
+
```yaml
|
|
1283
|
+
node-version: [22, 24]
|
|
1284
|
+
```
|
|
1285
|
+
|
|
1286
|
+
Keep the standalone Node 18 migration-script job: it executes the shipped migration file directly and does not claim the new extension runtime supports Node 18.
|
|
1287
|
+
|
|
1288
|
+
- [ ] **Step 2: Add cross-platform native-install and lexical contract coverage to `ci.yml`**
|
|
1289
|
+
|
|
1290
|
+
Add this job after `quality` and before `migration-node18`:
|
|
1291
|
+
|
|
1292
|
+
```yaml
|
|
1293
|
+
qmd-native-compatibility:
|
|
1294
|
+
name: QMD native compatibility (${{ matrix.os }})
|
|
1295
|
+
strategy:
|
|
1296
|
+
fail-fast: false
|
|
1297
|
+
matrix:
|
|
1298
|
+
os: [ubuntu-latest, macos-15, windows-latest]
|
|
1299
|
+
runs-on: ${{ matrix.os }}
|
|
1300
|
+
|
|
1301
|
+
steps:
|
|
1302
|
+
- uses: actions/checkout@v4
|
|
1303
|
+
|
|
1304
|
+
- uses: pnpm/action-setup@v4
|
|
1305
|
+
|
|
1306
|
+
- uses: actions/setup-node@v4
|
|
1307
|
+
with:
|
|
1308
|
+
node-version: 22
|
|
1309
|
+
cache: pnpm
|
|
1310
|
+
|
|
1311
|
+
- run: pnpm install --frozen-lockfile
|
|
1312
|
+
env:
|
|
1313
|
+
PUPPETEER_SKIP_DOWNLOAD: true
|
|
1314
|
+
|
|
1315
|
+
- name: Verify QMD SDK and model-free lexical search
|
|
1316
|
+
run: pnpm exec vitest run test/qmd-contract.test.ts --reporter=verbose
|
|
1317
|
+
env:
|
|
1318
|
+
QMD_FORCE_CPU: "1"
|
|
1319
|
+
```
|
|
1320
|
+
|
|
1321
|
+
Expected: the same package and lexical SDK test runs against native SQLite dependencies on Linux x64, macOS arm64, and Windows x64.
|
|
1322
|
+
|
|
1323
|
+
- [ ] **Step 3: Create the opt-in and scheduled model-backed workflow**
|
|
1324
|
+
|
|
1325
|
+
Create `.github/workflows/qmd-model-smoke.yml`:
|
|
1326
|
+
|
|
1327
|
+
```yaml
|
|
1328
|
+
name: QMD Model Smoke
|
|
1329
|
+
|
|
1330
|
+
on:
|
|
1331
|
+
workflow_dispatch:
|
|
1332
|
+
schedule:
|
|
1333
|
+
- cron: "0 4 * * 1"
|
|
1334
|
+
|
|
1335
|
+
concurrency:
|
|
1336
|
+
group: qmd-model-smoke
|
|
1337
|
+
cancel-in-progress: true
|
|
1338
|
+
|
|
1339
|
+
jobs:
|
|
1340
|
+
model-smoke:
|
|
1341
|
+
runs-on: ubuntu-latest
|
|
1342
|
+
timeout-minutes: 30
|
|
1343
|
+
|
|
1344
|
+
steps:
|
|
1345
|
+
- uses: actions/checkout@v4
|
|
1346
|
+
|
|
1347
|
+
- uses: pnpm/action-setup@v4
|
|
1348
|
+
|
|
1349
|
+
- uses: actions/setup-node@v4
|
|
1350
|
+
with:
|
|
1351
|
+
node-version: 22
|
|
1352
|
+
cache: pnpm
|
|
1353
|
+
|
|
1354
|
+
- run: pnpm install --frozen-lockfile
|
|
1355
|
+
env:
|
|
1356
|
+
PUPPETEER_SKIP_DOWNLOAD: true
|
|
1357
|
+
|
|
1358
|
+
- name: Cache QMD models
|
|
1359
|
+
uses: actions/cache@v4
|
|
1360
|
+
with:
|
|
1361
|
+
path: ~/.cache/qmd/models
|
|
1362
|
+
key: qmd-2.5.3-models-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
|
1363
|
+
|
|
1364
|
+
- name: Exercise embedding, vector, expansion, fusion, and reranking
|
|
1365
|
+
run: pnpm exec vitest run test/qmd-contract.test.ts --reporter=verbose
|
|
1366
|
+
env:
|
|
1367
|
+
QMD_FORCE_CPU: "1"
|
|
1368
|
+
QMD_MODEL_SMOKE: "1"
|
|
1369
|
+
```
|
|
1370
|
+
|
|
1371
|
+
- [ ] **Step 4: Document the benchmark contract and commands**
|
|
1372
|
+
|
|
1373
|
+
Create `docs/retrieval-benchmark.md`:
|
|
1374
|
+
|
|
1375
|
+
```markdown
|
|
1376
|
+
# Retrieval Benchmark
|
|
1377
|
+
|
|
1378
|
+
The Phase 1 benchmark records current heuristic recall quality before QMD powers production retrieval.
|
|
1379
|
+
|
|
1380
|
+
## Corpus
|
|
1381
|
+
|
|
1382
|
+
`test/fixtures/retrieval-benchmark/fixture.ts` contains 22 sanitized Markdown pages and 60 graded queries:
|
|
1383
|
+
|
|
1384
|
+
- 45 train queries
|
|
1385
|
+
- 15 immutable held-out queries
|
|
1386
|
+
- exact lookup, aliases, paraphrase, vague recollection, concepts, graph scope, evidence, time, conflicts, conclusions, synthesis, and unrelated negatives
|
|
1387
|
+
- English, Chinese, and mixed-language examples
|
|
1388
|
+
|
|
1389
|
+
The fixture must not contain raw home paths, email addresses, credentials, customer identifiers, or copied private notes. Sanitize representative phrasing before committing it.
|
|
1390
|
+
|
|
1391
|
+
The CJK query `什么是卡片盒笔记法` (zettelkasten-concept #4) is an intentional guaranteed miss for the current heuristic engine: the page body is English-only, so lexical score is zero by construction. Do not reword the page to make it pass; it exists to prove that only CJK-aware lexical handling (QMD's normalized FTS) can recover it.
|
|
1392
|
+
|
|
1393
|
+
## Judgments
|
|
1394
|
+
|
|
1395
|
+
Grades are:
|
|
1396
|
+
|
|
1397
|
+
- `3`: directly answers the query
|
|
1398
|
+
- `2`: useful supporting evidence or secondary answer
|
|
1399
|
+
- `1`: relevant context
|
|
1400
|
+
|
|
1401
|
+
Roles are `canonical` or `evidence`. Contradiction queries list every claim that must appear together.
|
|
1402
|
+
|
|
1403
|
+
## Metrics
|
|
1404
|
+
|
|
1405
|
+
The report records candidate Recall@20, MRR, nDCG@5, nDCG@10, canonical@3, evidence Recall@20, contradiction coverage, and automatic-recall false-positive rate. Scores are computed from ranked page IDs, never raw engine scores.
|
|
1406
|
+
|
|
1407
|
+
## Commands
|
|
1408
|
+
|
|
1409
|
+
Verify the committed baseline:
|
|
1410
|
+
|
|
1411
|
+
```bash
|
|
1412
|
+
pnpm benchmark:retrieval
|
|
1413
|
+
```
|
|
1414
|
+
|
|
1415
|
+
Regenerate after an intentional fixture or baseline-engine change:
|
|
1416
|
+
|
|
1417
|
+
```bash
|
|
1418
|
+
pnpm benchmark:retrieval:update
|
|
1419
|
+
pnpm benchmark:retrieval
|
|
1420
|
+
```
|
|
1421
|
+
|
|
1422
|
+
Never hand-edit `docs/superpowers/benchmarks/phase-1-current-baseline.json`. Every update must explain why the benchmark or baseline engine changed. Later phases may tune against `train`, but must not inspect or alter held-out judgments while tuning.
|
|
1423
|
+
```
|
|
1424
|
+
|
|
1425
|
+
- [ ] **Step 5: Document the pinned QMD compatibility contract**
|
|
1426
|
+
|
|
1427
|
+
Create `docs/qmd-compatibility.md`:
|
|
1428
|
+
|
|
1429
|
+
```markdown
|
|
1430
|
+
# QMD Compatibility
|
|
1431
|
+
|
|
1432
|
+
pi-llm-wiki's next major pins `@tobilu/qmd` **2.5.3**, the latest version published to npm when Phase 1 was planned.
|
|
1433
|
+
|
|
1434
|
+
## Runtime
|
|
1435
|
+
|
|
1436
|
+
- Node.js: `>=22.0.0`
|
|
1437
|
+
- TypeScript development peer: `^5.9.3`
|
|
1438
|
+
- Package manager: pnpm 9
|
|
1439
|
+
|
|
1440
|
+
Users requiring Node.js 18 must remain on the previous pi-llm-wiki major.
|
|
1441
|
+
|
|
1442
|
+
## Native compatibility
|
|
1443
|
+
|
|
1444
|
+
Clean-install CI covers:
|
|
1445
|
+
|
|
1446
|
+
- Linux x64
|
|
1447
|
+
- macOS arm64
|
|
1448
|
+
- Windows x64
|
|
1449
|
+
|
|
1450
|
+
QMD brings `better-sqlite3`, `sqlite-vec`, and `node-llama-cpp`. Failure to install required native packages is an installation failure, not a runtime lexical fallback.
|
|
1451
|
+
|
|
1452
|
+
## Model-free contract
|
|
1453
|
+
|
|
1454
|
+
`createStore`, `update`, `searchLex`, `getStatus`, and `close` must work without downloading or loading an embedding, expansion, or reranking model. Ordinary CI tests this path with `QMD_FORCE_CPU=1`.
|
|
1455
|
+
|
|
1456
|
+
## Model-backed contract
|
|
1457
|
+
|
|
1458
|
+
The scheduled/manual model smoke exercises:
|
|
1459
|
+
|
|
1460
|
+
- `embed`
|
|
1461
|
+
- `searchVector`
|
|
1462
|
+
- typed hybrid `search` with reranking disabled
|
|
1463
|
+
- `expandQuery`
|
|
1464
|
+
- expanded/reranked `search`
|
|
1465
|
+
|
|
1466
|
+
QMD stores default models under `~/.cache/qmd/models`. First use downloads roughly 2 GB across embedding, reranking, and expansion models. CI caches that directory. `QMD_FORCE_CPU=1` avoids GPU probing in compatibility jobs.
|
|
1467
|
+
|
|
1468
|
+
## Upgrade rule
|
|
1469
|
+
|
|
1470
|
+
Do not widen the QMD version range. A QMD upgrade requires:
|
|
1471
|
+
|
|
1472
|
+
1. exact-version lock update
|
|
1473
|
+
2. SDK contract and clean-install CI passing
|
|
1474
|
+
3. model smoke passing
|
|
1475
|
+
4. retrieval benchmark comparison before production use
|
|
1476
|
+
5. updated model and native-support documentation
|
|
1477
|
+
```
|
|
1478
|
+
|
|
1479
|
+
- [ ] **Step 6: Run all Phase 1 checks**
|
|
1480
|
+
|
|
1481
|
+
Run:
|
|
1482
|
+
|
|
1483
|
+
```bash
|
|
1484
|
+
pnpm benchmark:retrieval
|
|
1485
|
+
QMD_FORCE_CPU=1 pnpm exec vitest run test/qmd-contract.test.ts --reporter=verbose
|
|
1486
|
+
pnpm typecheck
|
|
1487
|
+
pnpm lint
|
|
1488
|
+
pnpm test
|
|
1489
|
+
pnpm build:mcp
|
|
1490
|
+
```
|
|
1491
|
+
|
|
1492
|
+
Expected: all commands exit 0; QMD model-backed smoke remains skipped locally unless explicitly enabled.
|
|
1493
|
+
|
|
1494
|
+
- [ ] **Step 7: Verify active recall code did not change**
|
|
1495
|
+
|
|
1496
|
+
Run:
|
|
1497
|
+
|
|
1498
|
+
```bash
|
|
1499
|
+
git diff 42d6fd1 -- extensions/llm-wiki/lib/recall.ts extensions/llm-wiki/index.ts mcp/operations.ts
|
|
1500
|
+
```
|
|
1501
|
+
|
|
1502
|
+
Expected: no diff. Phase 1 must not alter production recall or indexing behavior.
|
|
1503
|
+
|
|
1504
|
+
- [ ] **Step 8: Commit CI and documentation**
|
|
1505
|
+
|
|
1506
|
+
```bash
|
|
1507
|
+
git add .github/workflows/ci.yml .github/workflows/qmd-model-smoke.yml docs/retrieval-benchmark.md docs/qmd-compatibility.md
|
|
1508
|
+
git commit -m "ci: verify QMD compatibility"
|
|
1509
|
+
```
|
|
1510
|
+
|
|
1511
|
+
- [ ] **Step 9: Confirm a clean phase boundary**
|
|
1512
|
+
|
|
1513
|
+
Run:
|
|
1514
|
+
|
|
1515
|
+
```bash
|
|
1516
|
+
git status --short
|
|
1517
|
+
git log --oneline -6
|
|
1518
|
+
```
|
|
1519
|
+
|
|
1520
|
+
Expected: clean working tree and six Phase 1 commits after the planning commits. Active recall remains current heuristic retrieval; Phase 2 may now plan validated per-vault QMD indexing.
|