pi-mega-compact 0.4.0

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 (69) hide show
  1. package/LICENSE +24 -0
  2. package/README.md +375 -0
  3. package/extensions/DASHBOARD.md +160 -0
  4. package/extensions/dashboard-server.test.ts +124 -0
  5. package/extensions/dashboard-server.ts +459 -0
  6. package/extensions/error-patterns.ts +175 -0
  7. package/extensions/mega-compact.test.ts +351 -0
  8. package/extensions/mega-compact.ts +846 -0
  9. package/extensions/openclaw-mega-compact.ts +370 -0
  10. package/package.json +61 -0
  11. package/src/adapt.ts +120 -0
  12. package/src/boundary.test.ts +61 -0
  13. package/src/boundary.ts +94 -0
  14. package/src/canary.ts +126 -0
  15. package/src/compact.test.ts +99 -0
  16. package/src/compact.ts +262 -0
  17. package/src/config/dedup.ts +120 -0
  18. package/src/config.ts +15 -0
  19. package/src/dedup/dedup.test.ts +46 -0
  20. package/src/dedup/digest.ts +40 -0
  21. package/src/dedup/l1-lsh.ts +67 -0
  22. package/src/dedup/l1-minhash.ts +90 -0
  23. package/src/dedup/l1-verify.ts +55 -0
  24. package/src/dedup/l1.test.ts +57 -0
  25. package/src/dedup/mmr.ts +54 -0
  26. package/src/dedup/normalize.ts +41 -0
  27. package/src/dedup/raptor/guardrails.ts +112 -0
  28. package/src/dedup/raptor/index.ts +118 -0
  29. package/src/dedup/raptor/kmeans.ts +156 -0
  30. package/src/dedup/raptor/raptor.test.ts +238 -0
  31. package/src/dedup/raptor/retrieval.ts +102 -0
  32. package/src/dedup/raptor/summarizer.ts +91 -0
  33. package/src/dedup/raptor/tree.ts +254 -0
  34. package/src/dedup/sprint12.test.ts +242 -0
  35. package/src/dedup/topk.ts +61 -0
  36. package/src/dedup-engine.test.ts +609 -0
  37. package/src/e2e.test.ts +843 -0
  38. package/src/embedder.ts +111 -0
  39. package/src/engine.test.ts +123 -0
  40. package/src/engine.ts +192 -0
  41. package/src/extractive.test.ts +156 -0
  42. package/src/extractive.ts +265 -0
  43. package/src/httpEmbedder.ts +154 -0
  44. package/src/log.test.ts +47 -0
  45. package/src/log.ts +60 -0
  46. package/src/monitoring.ts +171 -0
  47. package/src/ratio.bench.test.ts +1316 -0
  48. package/src/recall.integration.test.ts +96 -0
  49. package/src/recall.test.ts +59 -0
  50. package/src/recall.ts +100 -0
  51. package/src/sprint14.test.ts +245 -0
  52. package/src/store/backfill.ts +263 -0
  53. package/src/store/bloom.ts +122 -0
  54. package/src/store/compression.test.ts +83 -0
  55. package/src/store/compression.ts +203 -0
  56. package/src/store/integrity.ts +65 -0
  57. package/src/store/migrate.test.ts +158 -0
  58. package/src/store/migrate.ts +108 -0
  59. package/src/store/sprint10.test.ts +182 -0
  60. package/src/store/sqlite.ts +519 -0
  61. package/src/store.test.ts +169 -0
  62. package/src/store.ts +192 -0
  63. package/src/supersede.test.ts +42 -0
  64. package/src/supersede.ts +67 -0
  65. package/src/tokens.ts +35 -0
  66. package/src/types.test.ts +10 -0
  67. package/src/types.ts +49 -0
  68. package/src/vectorStore.test.ts +480 -0
  69. package/src/vectorStore.ts +544 -0
@@ -0,0 +1,102 @@
1
+ /**
2
+ * retrieval.ts — staged RAPTOR retrieval (Sprint 13, Phase 6).
3
+ *
4
+ * Given a built tree (shadow or live), answer a query by:
5
+ * 1. ANN: score the top-level nodes at every level by cosine to the query.
6
+ * 2. expand: take the top-M nodes (across levels) and descend.
7
+ * 3. BFS: from those anchors, walk down to leaf nodes.
8
+ * 4. MMR: diversify the resulting leaf set before returning.
9
+ *
10
+ * This module is pure query logic over an in-memory RaptorTree. In Sprint 13 it
11
+ * is exercised only in shadow/eval; the live store (vectorStore.search) is NOT
12
+ * replaced until Sprint 14 promotes RAPTOR.
13
+ */
14
+
15
+ import type { Embedder, Vector } from "../../embedder.js";
16
+ import { cosineSimilarity } from "../../embedder.js";
17
+ import { mmrRerank } from "../mmr.js";
18
+ import type { RaptorTree, RaptorNode } from "./tree.js";
19
+
20
+ export interface RaptorRetrieveOptions {
21
+ embedder: Embedder;
22
+ /** How many top nodes per level to expand from. */
23
+ topM?: number;
24
+ /** Final number of leaf nodes to return. */
25
+ k?: number;
26
+ /** MMR diversity weight. */
27
+ mmrLambda?: number;
28
+ }
29
+
30
+ /** Any child id not present in the node map is a raw leaf id. */
31
+ function isLeafId(id: string, tree: RaptorTree): boolean {
32
+ return !tree.nodes.has(id);
33
+ }
34
+
35
+ /** All leaf (raw) ids reachable beneath a node via BFS. */
36
+ function leafDescendants(node: RaptorNode, tree: RaptorTree): string[] {
37
+ const out: string[] = [];
38
+ const queue = [node];
39
+ while (queue.length) {
40
+ const cur = queue.shift()!;
41
+ for (const childId of cur.children) {
42
+ if (isLeafId(childId, tree)) out.push(childId);
43
+ else {
44
+ const child = tree.nodes.get(childId);
45
+ if (child) queue.push(child);
46
+ }
47
+ }
48
+ }
49
+ return out;
50
+ }
51
+
52
+ /**
53
+ * Staged expansion retrieval over a RAPTOR tree.
54
+ *
55
+ * Returns up to `k` leaf ids, diversified by MMR. Deterministic given the
56
+ * tree + query. Throws nothing — returns [] on empty tree.
57
+ */
58
+ export function stagedExpansion(
59
+ query: string,
60
+ tree: RaptorTree,
61
+ opts: RaptorRetrieveOptions,
62
+ ): string[] {
63
+ if (!tree.rootId) return [];
64
+ const embedder = opts.embedder;
65
+ const topM = opts.topM ?? 3;
66
+ const k = opts.k ?? 5;
67
+ const lambda = opts.mmrLambda ?? 0.5;
68
+
69
+ const qv = embedder.embed(query);
70
+
71
+ // 1. ANN: score every node at every level by cosine to the query.
72
+ const scored = [...tree.nodes.values()].map((n) => ({
73
+ node: n,
74
+ score: cosineSimilarity(qv, n.embedding),
75
+ }));
76
+
77
+ // 2. expand: top-M nodes overall (BFS anchors).
78
+ const anchors = scored
79
+ .slice()
80
+ .sort((a, b) => b.score - a.score)
81
+ .slice(0, topM)
82
+ .map((s) => s.node);
83
+
84
+ // 3. BFS to leaves from those anchors.
85
+ const leaves = new Map<string, RaptorNode>();
86
+ for (const a of anchors) {
87
+ for (const lid of leafDescendants(a, tree)) {
88
+ // Represent each leaf by its nearest internal parent so we can score it.
89
+ // (The leaf's own centroid is stored on the level-0 node that wraps it.)
90
+ const parent = [...tree.nodes.values()].find((n) => n.children.includes(lid));
91
+ if (parent) leaves.set(lid, parent);
92
+ }
93
+ }
94
+
95
+ // 4. MMR diversify the expanded leaf set by their (parent) embeddings.
96
+ const items = [...leaves.entries()].map(([lid, n]) => ({
97
+ item: lid,
98
+ vector: n.embedding as Vector,
99
+ relevance: cosineSimilarity(qv, n.embedding),
100
+ }));
101
+ return mmrRerank(items, k, lambda);
102
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * summarizer.ts — per-cluster summary for the RAPTOR tree (Sprint 13, Phase 6).
3
+ *
4
+ * Default: pure extractive (deterministic, zero network, zero model) reusing
5
+ * extractive.ts. Optional: a LOCAL Ollama model (llama3.2:3b by default) when
6
+ * MEGACOMPACT_RAPTOR_MODEL is set — localhost only, same PREVENT-PI-004
7
+ * exception class as HttpEmbedder/the dashboard. No remote API is ever called.
8
+ *
9
+ * The summarizer returns structured text + a token estimate. Faithfulness is
10
+ * enforced downstream by guardrails.ts — this module only produces candidates.
11
+ */
12
+
13
+ import type { EngineMessage } from "../../types.js";
14
+ import { extractiveSummarize } from "../../extractive.js";
15
+ import { estimateBlockTokens } from "../../tokens.js";
16
+ import { spawnSync } from "node:child_process"; // guardrails-allow PREVENT-PI-004: localhost-only user-spawned Ollama server (BYO local model, never remote)
17
+
18
+ export interface ClusterSummary {
19
+ summary: string;
20
+ tokenEstimate: number;
21
+ }
22
+
23
+ /** The local Ollama endpoint (loopback). Read lazily so tests can avoid it. */
24
+ function ollamaEndpoint(): { url: string; model: string } | null {
25
+ const model = process.env.MEGACOMPACT_RAPTOR_MODEL;
26
+ if (!model) return null;
27
+ const base = process.env.MEGACOMPACT_RAPTOR_URL ?? "http://127.0.0.1:11434";
28
+ // Guard: only loopback is permitted (remote Ollama would violate PREVENT-PI-004).
29
+ if (!/^https?:\/\/(localhost|127\.0\.0\.1)([:/]|$)/.test(base)) {
30
+ throw new Error(
31
+ `MEGACOMPACT_RAPTOR_URL must be localhost/127.0.0.1 (got ${base}). ` +
32
+ `Remote Ollama is not allowed (PREVENT-PI-004).`,
33
+ );
34
+ }
35
+ return { url: `${base}/api/generate`, model };
36
+ }
37
+
38
+ /**
39
+ * Extractive summarization of a cluster's source messages. Deterministic and
40
+ * fully local — the on-by-default path.
41
+ */
42
+ export function extractiveClusterSummary(messages: EngineMessage[]): ClusterSummary {
43
+ const s = extractiveSummarize(messages);
44
+ return { summary: s.topicSummary, tokenEstimate: s.tokenEstimate };
45
+ }
46
+
47
+ /**
48
+ * Build a summary for one cluster of source messages.
49
+ *
50
+ * Uses local Ollama when MEGACOMPACT_RAPTOR_MODEL is set (localhost-only);
51
+ * otherwise falls back to deterministic extractive. The `fetch` is a localhost
52
+ * call inside the PREVENT-PI-004 exception — annotated accordingly.
53
+ */
54
+ export function summarizeCluster(messages: EngineMessage[]): ClusterSummary {
55
+ const ollama = ollamaEndpoint();
56
+ if (!ollama) return extractiveClusterSummary(messages);
57
+ return ollamaSummarize(messages, ollama);
58
+ }
59
+
60
+ function ollamaSummarize(messages: EngineMessage[], ollama: { url: string; model: string }): ClusterSummary {
61
+ // The fetch below is localhost-only (loopback Ollama) — the PREVENT-PI-004
62
+ // sanctioned local-model exceptions (same class as /dashboard, HttpEmbedder).
63
+ const prompt = messages.map((m) => `${m.role}: ${m.text}`).join("\n");
64
+ // Synchronous bridge: spawnSync an inline worker so the call blocks without
65
+ // deadlocking fetch (mirrors HttpEmbedder — Atomics.wait on main thread would
66
+ // hang). A blocked main thread cannot pump the socket.
67
+ const WORKER = String.raw`
68
+ const url = process.env.R_URL, model = process.env.R_MODEL, prompt = process.env.R_PROMPT;
69
+ try {
70
+ const r = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ model, prompt, stream: false }) }); // guardrails-allow PREVENT-PI-004: localhost-only user-spawned Ollama server (BYO local model, never remote)
71
+ const j = await r.json();
72
+ process.stdout.write(JSON.stringify({ ok: r.ok, text: j.response || "" }));
73
+ } catch (e) {
74
+ process.stdout.write(JSON.stringify({ ok: false, error: String(e && e.message ? e.message : e) }));
75
+ }
76
+ `;
77
+ const res = spawnSync(process.execPath, ["-e", WORKER], {
78
+ encoding: "utf8",
79
+ env: { ...process.env, R_URL: ollama.url, R_MODEL: ollama.model, R_PROMPT: prompt },
80
+ });
81
+ let parsed: { ok: boolean; text?: string; error?: string } = { ok: false, error: "no response" };
82
+ if (typeof res.stdout === "string" && res.stdout.length > 0) {
83
+ try { parsed = JSON.parse(res.stdout); } catch { parsed = { ok: false, error: "bad json" }; }
84
+ }
85
+ if (!parsed.ok || !parsed.text) {
86
+ // Ollama unavailable → deterministic extractive fallback (never fail the build).
87
+ return extractiveClusterSummary(messages);
88
+ }
89
+ const summary = parsed.text.trim();
90
+ return { summary, tokenEstimate: estimateBlockTokens(summary) };
91
+ }
@@ -0,0 +1,254 @@
1
+ /**
2
+ * tree.ts — RAPTOR hierarchical summary-tree builder (Sprint 13, Phase 6).
3
+ *
4
+ * Builds a multi-level tree of summary nodes over leaf chunks: leaves are the
5
+ * original regions; each higher level summarizes clusters of the level below
6
+ * until a single root remains. QA ops: a wall-clock budget guard — on exhaustion
7
+ * we build an extractive fallback root. <10 leaves → a single summary node.
8
+ *
9
+ * Node model (kept simple + flat): every RaptorNode stores the LIST OF LEAF IDS
10
+ * it ultimately covers in `children` (not a mix of node/leaf ids). So the node
11
+ * map holds ONLY internal summary nodes — never per-leaf wrappers — which is
12
+ * what makes RAPTOR consolidate (nodes.size << leaves) and makes retrieval's
13
+ * leaf walk trivial.
14
+ *
15
+ * PREVENT-PI-004: no network here. summarizeCluster() may call a localhost
16
+ * Ollama (annotated in summarizer.ts); extractive is the default.
17
+ */
18
+
19
+ import type { Embedder, Vector } from "../../embedder.js";
20
+ import { kmeanspp, meanVector } from "./kmeans.js";
21
+ import { summarizeCluster } from "./summarizer.js";
22
+ import { applyHallucinationGuardrails, sourceTokenSet, type QualityMarker } from "./guardrails.js";
23
+ import type { EngineMessage } from "../../types.js";
24
+
25
+ export interface RaptorNode {
26
+ id: string;
27
+ level: number;
28
+ parentId: string | null;
29
+ /** Leaf ids this node ultimately covers (flattened through the hierarchy). */
30
+ children: string[];
31
+ summary: string;
32
+ embedding: Vector; // centroid of the covered leaves
33
+ qualityMarker: QualityMarker;
34
+ tokenEstimate: number;
35
+ }
36
+
37
+ export interface RaptorTree {
38
+ nodes: Map<string, RaptorNode>;
39
+ rootId: string | null;
40
+ levels: number;
41
+ /** True when the budget forced an extractive fallback root. */
42
+ timedOut: boolean;
43
+ }
44
+
45
+ export interface Leaf {
46
+ id: string;
47
+ messages: EngineMessage[];
48
+ /** Precomputed source text (for grounding) + embedding (leaf centroid). */
49
+ sourceText: string;
50
+ embedding: Vector;
51
+ }
52
+
53
+ export interface BuildOptions {
54
+ embedder: Embedder;
55
+ /** Max wall-clock ms for the whole build (QA ops budget). */
56
+ budgetMs?: number;
57
+ /** Target clusters per level (k for k-means). */
58
+ clustersPerLevel?: number;
59
+ /** Consistency gate (guardrails.ts). */
60
+ consistencyThreshold?: number;
61
+ /** Injectable id generator (deterministic in tests). */
62
+ nextId?: (level: number, index: number) => string;
63
+ /** Injectable clock for the budget guard (ms). */
64
+ now?: () => number;
65
+ }
66
+
67
+ const DEFAULT_BUDGET_MS = 5000;
68
+ const DEFAULT_CLUSTERS = 5;
69
+
70
+ function defaultNextId(level: number, index: number): string {
71
+ return `r${level}_${index}`;
72
+ }
73
+
74
+ /** An item being clustered at any level: a leaf, or a grouping of leaves. */
75
+ interface ClusterItem {
76
+ id: string;
77
+ embedding: Vector;
78
+ leafIds: string[];
79
+ /** Source messages for summarization (flattened). */
80
+ messages: EngineMessage[];
81
+ /** Source texts for grounding (flattened). */
82
+ sources: string[];
83
+ }
84
+
85
+ function summarizeInto(
86
+ item: ClusterItem,
87
+ centroid: Vector,
88
+ embedder: Embedder,
89
+ consistencyThreshold?: number,
90
+ ): { summary: string; tokenEstimate: number; qualityMarker: QualityMarker } {
91
+ let summary = summarizeCluster(item.messages);
92
+ const guard = applyHallucinationGuardrails({
93
+ summary: summary.summary,
94
+ sources: item.sources,
95
+ centroid,
96
+ embedder,
97
+ sourceTokens: sourceTokenSet(item.sources),
98
+ consistencyThreshold,
99
+ });
100
+ if (guard.marker === "extractive_fallback") {
101
+ summary = summarizeCluster(item.messages); // deterministic extractive text
102
+ }
103
+ return {
104
+ summary: summary.summary,
105
+ tokenEstimate: summary.tokenEstimate,
106
+ qualityMarker: guard.marker === "extractive_fallback" ? "low" : guard.marker,
107
+ };
108
+ }
109
+
110
+ /**
111
+ * Build a RAPTOR tree from leaf chunks. Synchronous; guarded by an elapsed-time
112
+ * budget. Returns a tree whose `nodes` map holds ONLY internal summary nodes.
113
+ */
114
+ export function buildRaptorTree(leaves: Leaf[], opts: BuildOptions): RaptorTree {
115
+ const embedder = opts.embedder;
116
+ const budgetMs = opts.budgetMs ?? DEFAULT_BUDGET_MS;
117
+ const clustersPerLevel = opts.clustersPerLevel ?? DEFAULT_CLUSTERS;
118
+ const nextId = opts.nextId ?? defaultNextId;
119
+ const now = opts.now ?? (() => Date.now());
120
+ const start = now();
121
+ const within = () => now() - start <= budgetMs;
122
+
123
+ const nodes = new Map<string, RaptorNode>();
124
+
125
+ // <10 leaves → single summary root (no hierarchy needed).
126
+ if (leaves.length < 10) {
127
+ const item: ClusterItem = {
128
+ id: "root",
129
+ embedding: meanVector(leaves.map((l) => l.embedding)),
130
+ leafIds: leaves.map((l) => l.id),
131
+ messages: leaves.flatMap((l) => l.messages),
132
+ sources: leaves.map((l) => l.sourceText),
133
+ };
134
+ const centroid = item.embedding;
135
+ const { summary, tokenEstimate, qualityMarker } = summarizeInto(item, centroid, embedder, opts.consistencyThreshold);
136
+ const rootId = nextId(0, 0);
137
+ nodes.set(rootId, {
138
+ id: rootId,
139
+ level: 0,
140
+ parentId: null,
141
+ children: item.leafIds,
142
+ summary,
143
+ embedding: centroid,
144
+ qualityMarker,
145
+ tokenEstimate,
146
+ });
147
+ return { nodes, rootId, levels: 1, timedOut: false };
148
+ }
149
+
150
+ let currentLevel: ClusterItem[] = leaves.map((l) => ({
151
+ id: l.id,
152
+ embedding: l.embedding,
153
+ leafIds: [l.id],
154
+ messages: l.messages,
155
+ sources: [l.sourceText],
156
+ }));
157
+
158
+ let level = 0;
159
+ while (currentLevel.length > 1) {
160
+ if (!within()) return extractiveFallbackRoot(leaves, nodes, nextId);
161
+
162
+ // Once we're down to a handful of items, collapse them all into one root.
163
+ // (k === currentLevel.length would make every item its own singleton
164
+ // cluster and never shrink — an infinite loop until the budget blows.)
165
+ if (currentLevel.length <= clustersPerLevel) {
166
+ const merged: ClusterItem = {
167
+ id: "merge",
168
+ embedding: meanVector(currentLevel.map((c) => c.embedding)),
169
+ leafIds: currentLevel.flatMap((c) => c.leafIds),
170
+ messages: currentLevel.flatMap((c) => c.messages),
171
+ sources: currentLevel.flatMap((c) => c.sources),
172
+ };
173
+ const centroid = merged.embedding;
174
+ const { summary, tokenEstimate, qualityMarker } = summarizeInto(merged, centroid, embedder, opts.consistencyThreshold);
175
+ const rootId = nextId(level + 1, 0);
176
+ nodes.set(rootId, {
177
+ id: rootId,
178
+ level: level + 1,
179
+ parentId: null,
180
+ children: merged.leafIds,
181
+ summary,
182
+ embedding: centroid,
183
+ qualityMarker,
184
+ tokenEstimate,
185
+ });
186
+ return { nodes, rootId, levels: level + 2, timedOut: false };
187
+ }
188
+
189
+ const k = Math.max(1, Math.min(clustersPerLevel, currentLevel.length));
190
+ const clustered = kmeanspp(currentLevel.map((c) => c.embedding), k, { seed: 0x1234 + level });
191
+
192
+ const groups: ClusterItem[][] = Array.from({ length: clustered.k }, () => []);
193
+ clustered.assignments.forEach((c, i) => groups[c].push(currentLevel[i]));
194
+
195
+ const nextLevel: ClusterItem[] = [];
196
+ for (let g = 0; g < groups.length; g++) {
197
+ const group = groups[g];
198
+ if (group.length === 0) continue;
199
+ const merged: ClusterItem = {
200
+ id: nextId(level + 1, g),
201
+ embedding: clustered.centroids[g],
202
+ leafIds: group.flatMap((c) => c.leafIds),
203
+ messages: group.flatMap((c) => c.messages),
204
+ sources: group.flatMap((c) => c.sources),
205
+ };
206
+ const { summary, tokenEstimate, qualityMarker } = summarizeInto(merged, merged.embedding, embedder, opts.consistencyThreshold);
207
+ nodes.set(merged.id, {
208
+ id: merged.id,
209
+ level: level + 1,
210
+ parentId: null,
211
+ children: merged.leafIds,
212
+ summary,
213
+ embedding: merged.embedding,
214
+ qualityMarker,
215
+ tokenEstimate,
216
+ });
217
+ nextLevel.push(merged);
218
+ }
219
+ currentLevel = nextLevel;
220
+ level++;
221
+ }
222
+
223
+ const root = currentLevel[0];
224
+ return {
225
+ nodes,
226
+ rootId: root ? root.id : null,
227
+ levels: level + 1,
228
+ timedOut: false,
229
+ };
230
+ }
231
+
232
+ /**
233
+ * Budget-exceeded fallback: build a single deterministic extractive root over
234
+ * all leaves and mark it low quality. Keeps a valid (if shallow) tree.
235
+ */
236
+ function extractiveFallbackRoot(
237
+ leaves: Leaf[],
238
+ nodes: Map<string, RaptorNode>,
239
+ nextId: (level: number, index: number) => string,
240
+ ): RaptorTree {
241
+ const summary = summarizeCluster(leaves.flatMap((l) => l.messages));
242
+ const rootId = nextId(99, 0);
243
+ nodes.set(rootId, {
244
+ id: rootId,
245
+ level: 99,
246
+ parentId: null,
247
+ children: leaves.map((l) => l.id),
248
+ summary: summary.summary,
249
+ embedding: meanVector(leaves.map((l) => l.embedding)),
250
+ qualityMarker: "low",
251
+ tokenEstimate: summary.tokenEstimate,
252
+ });
253
+ return { nodes, rootId, levels: 2, timedOut: true };
254
+ }
@@ -0,0 +1,242 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { VectorStore, L2_ENABLED } from "../vectorStore.js";
7
+ import { mmrRerank } from "./mmr.js";
8
+ import { topK } from "./topk.js";
9
+ import { cosineSimilarity, defaultEmbedder } from "../embedder.js";
10
+ import { upsertCheckpoint } from "../store/sqlite.js";
11
+ import type { StoredCheckpoint } from "../store.js";
12
+
13
+ const baseTmp = mkdtempSync(join(tmpdir(), "mc-s12-"));
14
+ let counter = 0;
15
+ function store(opts: Record<string, unknown> = {}) {
16
+ const dir = join(baseTmp, `run-${counter++}`);
17
+ return new VectorStore({ stateDir: dir, ...opts });
18
+ }
19
+
20
+ // --- MMR diversity ---------------------------------------------------------
21
+
22
+ test("mmrRerank diversifies: a cluster yields distinct-relevance results", () => {
23
+ const e = defaultEmbedder();
24
+ // Three near-identical vectors + one distinct.
25
+ const v = e.embed("the compiler optimized the parser hot loop");
26
+ const v2 = e.embed("the compiler optimized the parser hot loops"); // near-dup of v
27
+ const v3 = e.embed("the compiler optimized the parser hot loop now"); // near-dup of v
28
+ const vDistinct = e.embed("the database added a covering index for queries");
29
+ const items = [
30
+ { item: "a", vector: v, relevance: 0.9 },
31
+ { item: "b", vector: v2, relevance: 0.88 },
32
+ { item: "c", vector: v3, relevance: 0.87 },
33
+ { item: "d", vector: vDistinct, relevance: 0.5 },
34
+ ];
35
+ const ranked = mmrRerank(items, 2, 0.5);
36
+ assert.equal(ranked.length, 2);
37
+ // The near-dup cluster (a) and the distinct one (d) should both survive.
38
+ assert.ok(ranked.includes("a"));
39
+ assert.ok(ranked.includes("d"));
40
+ assert.ok(!ranked.includes("b") || !ranked.includes("c"));
41
+ });
42
+
43
+ test("mmrRerank with lambda=1 is pure relevance ranking", () => {
44
+ const items = [
45
+ { item: "low", vector: [1, 0, 0], relevance: 0.1 },
46
+ { item: "high", vector: [0, 1, 0], relevance: 0.9 },
47
+ ];
48
+ const ranked = mmrRerank(items, 2, 1);
49
+ assert.deepEqual(ranked, ["high", "low"]);
50
+ });
51
+
52
+ // --- Heap top-k ------------------------------------------------------------
53
+
54
+ test("topK matches brute-force full sort on a fixture", () => {
55
+ const items = Array.from({ length: 1000 }, (_, i) => ({ item: i, score: Math.sin(i) * 100 + (i % 7) }));
56
+ for (const k of [1, 3, 10, 50]) {
57
+ const heap = topK(items, k).map((s) => s.item).sort((a, b) => b - a);
58
+ const brute = [...items].sort((a, b) => b.score - a.score).slice(0, k).map((s) => s.item).sort((a, b) => b - a);
59
+ assert.deepEqual(heap, brute, `topK(${k}) should match brute force`);
60
+ }
61
+ });
62
+
63
+ test("topK with k >= n returns all (descending by score)", () => {
64
+ const items = [{ item: "x", score: 1 }, { item: "y", score: 2 }];
65
+ assert.deepEqual(topK(items, 5).map((s) => s.item), ["y", "x"]);
66
+ });
67
+
68
+ // --- Empty-vector guard ----------------------------------------------------
69
+
70
+ test("cosineSimilarity guards empty vector → 0 (no NaN)", () => {
71
+ assert.equal(cosineSimilarity([], [1, 2, 3]), 0);
72
+ assert.equal(cosineSimilarity([0, 0, 0], [1, 2, 3]), 0);
73
+ assert.ok(!Number.isNaN(cosineSimilarity([], [])));
74
+ });
75
+
76
+ // --- L2_ENABLED flag -------------------------------------------------------
77
+
78
+ test("L2_ENABLED defaults true; search still returns hits", () => {
79
+ assert.equal(L2_ENABLED, true);
80
+ const s = store();
81
+ s.add({ sessionId: "sess_l2", summary: "investigated the parser", regionText: "investigated src/parser.ts and added a tokenizer", timestamp: 1 });
82
+ const hits = s.search("sess_l2", "src/parser.ts tokenizer", 3);
83
+ assert.ok(hits.length >= 1);
84
+ });
85
+
86
+ test("L2_ENABLED=false skips semantic tier but L0/L1 still work", () => {
87
+ const s = store({ l2Enabled: false });
88
+ const r1 = s.add({ sessionId: "sess_l2off", summary: "x", regionText: "the auth module validates the session token", timestamp: 1 });
89
+ const r2 = s.add({ sessionId: "sess_l2off", summary: "x", regionText: "the auth module validates the session token", timestamp: 2 });
90
+ assert.equal(r2.deduped, true); // L0 catches exact
91
+ assert.equal(r1.deduped, false);
92
+ });
93
+
94
+ // --- SemDeDup --------------------------------------------------------------
95
+
96
+ // Seed a legacy session directly (bypassing add-time dedup tiers) so SemDeDup
97
+ // has a redundant pair to prune — its real job is offline cleanup of data that
98
+ // predates / escaped the online cascade.
99
+ function seed(dir: string, sessionId: string, rows: { id: string; text: string; tok: number }[]): void {
100
+ const e = defaultEmbedder();
101
+ for (const r of rows) {
102
+ const cp: StoredCheckpoint = {
103
+ checkpointId: r.id,
104
+ sessionId,
105
+ summary: r.text,
106
+ keyDecisions: [],
107
+ nextSteps: [],
108
+ filesModified: [],
109
+ tokenEstimate: r.tok,
110
+ regionHash: `r-${r.id}`,
111
+ embedding: e.embed(r.text),
112
+ timestamp: 1,
113
+ };
114
+ upsertCheckpoint(cp, dir);
115
+ }
116
+ }
117
+
118
+ test("semDedup marks redundant near-identical rows 'removed' and search excludes them", () => {
119
+ const dir = join(baseTmp, `run-${counter++}`);
120
+ const s = new VectorStore({ stateDir: dir });
121
+ seed(dir, "sess_sd", [
122
+ { id: "chkpt_001", text: "the cache stores parsed ast nodes for fast lookup", tok: 100 },
123
+ { id: "chkpt_002", text: "the cache stores parsed ast nodes for fast lookup and reuse", tok: 900 },
124
+ ]);
125
+ const removed = s.semDedup("sess_sd", 0.85);
126
+ assert.equal(removed, 1);
127
+ const st = s.list("sess_sd");
128
+ const dropped = st.find((c) => c.dedupStatus === "removed");
129
+ assert.ok(dropped);
130
+ assert.equal(dropped.checkpointId, "chkpt_001"); // lower tokenEstimate removed
131
+ // Search excludes the removed row (only one active remains).
132
+ const hits = s.search("sess_sd", "cache parsed ast nodes", 5);
133
+ assert.equal(hits.length, 1);
134
+ });
135
+
136
+ test("semDedup is idempotent (re-run removes nothing new)", () => {
137
+ const dir = join(baseTmp, `run-${counter++}`);
138
+ const s = new VectorStore({ stateDir: dir });
139
+ seed(dir, "sess_sd2", [
140
+ { id: "chkpt_001", text: "identical region text for the dedup job now", tok: 100 },
141
+ { id: "chkpt_002", text: "identical region text for the dedup job right now", tok: 200 },
142
+ ]);
143
+ const first = s.semDedup("sess_sd2", 0.85);
144
+ const second = s.semDedup("sess_sd2", 0.85);
145
+ assert.equal(first, 1);
146
+ assert.equal(second, 0);
147
+ });
148
+
149
+ // --- HttpEmbedder (BYO localhost backend) ----------------------------------
150
+ // Hermetic: a self-test server returns a deterministic embedding. The server
151
+ // runs in an INDEPENDENT child process (its own event loop) so that when
152
+ // HttpEmbedder.embed() blocks the *parent* via spawnSync, the server can still
153
+ // accept the connection — hosting it in-process would deadlock.
154
+
155
+ import { spawn, type ChildProcess } from "node:child_process";
156
+
157
+ const ECHO_SERVER = String.raw`
158
+ import { createServer } from "node:http";
159
+ const s = createServer((req, res) => {
160
+ let b = "";
161
+ req.on("data", (c) => (b += c));
162
+ req.on("end", () => {
163
+ const input = JSON.parse(b).input || [""];
164
+ const text = (input[0] || "").toLowerCase().replace(/\s+/g, " ");
165
+ const vec = new Array(8).fill(0);
166
+ for (const w of text.split(" ")) {
167
+ let h = 0x811c9dc5;
168
+ for (let i = 0; i < w.length; i++) { h ^= w.charCodeAt(i); h = Math.imul(h, 0x01000193); }
169
+ vec[h % 8] += 1;
170
+ }
171
+ res.setHeader("content-type", "application/json");
172
+ res.end(JSON.stringify({ data: [{ embedding: vec }] }));
173
+ });
174
+ });
175
+ s.listen(0, "127.0.0.1", () => process.stdout.write(String(s.address().port)));
176
+ `;
177
+
178
+ // A simpler shape-only server: always returns { data: [[0.1,0.2,0.3]] }.
179
+ const DATA_ARR_SERVER = String.raw`
180
+ import { createServer } from "node:http";
181
+ const s = createServer((_req, res) => {
182
+ res.setHeader("content-type", "application/json");
183
+ res.end(JSON.stringify({ data: [[0.1, 0.2, 0.3]] }));
184
+ });
185
+ s.listen(0, "127.0.0.1", () => process.stdout.write(String(s.address().port)));
186
+ `;
187
+
188
+ /** Spawn an independent echo server; resolves with its url once the port is up. */
189
+ function startEchoServer(shape?: "data-arr"): Promise<{ url: string; proc: ChildProcess }> {
190
+ const script = shape === "data-arr" ? DATA_ARR_SERVER : ECHO_SERVER;
191
+ const proc = spawn(process.execPath, ["-e", script], { stdio: ["ignore", "pipe", "ignore"] });
192
+ return new Promise((resolve, reject) => {
193
+ let buf = "";
194
+ proc.stdout!.on("data", (d) => {
195
+ buf += d.toString();
196
+ const m = buf.match(/(\d+)/);
197
+ if (m) resolve({ url: `http://127.0.0.1:${m[1]}`, proc });
198
+ });
199
+ proc.on("error", reject);
200
+ setTimeout(() => reject(new Error("echo server did not start")), 5000);
201
+ });
202
+ }
203
+
204
+ test("HttpEmbedder parses OpenAI-style response and resolves dim", async () => {
205
+ const { url, proc } = await startEchoServer();
206
+ try {
207
+ const { HttpEmbedder } = await import("../httpEmbedder.js");
208
+ const emb = new HttpEmbedder({ url });
209
+ assert.equal(emb.dim, 0); // unknown until first embed
210
+ const v = emb.embed("the parser optimized the hot loop");
211
+ assert.equal(v.length, 8); // echo server returns 8-dim
212
+ assert.equal(emb.dim, 8); // resolved after first call
213
+ const v2 = emb.embed("the parser optimized the hot loop");
214
+ assert.deepEqual(v2, v); // deterministic
215
+ } finally {
216
+ proc.kill();
217
+ }
218
+ });
219
+
220
+ test("HttpEmbedder tolerant parser: accepts { data: [[...]] } shape", async () => {
221
+ const { url, proc } = await startEchoServer("data-arr");
222
+ try {
223
+ const { HttpEmbedder } = await import("../httpEmbedder.js");
224
+ const emb = new HttpEmbedder({ url });
225
+ // embed() L2-normalizes; verify the { data: [[...]] } shape parsed (dim 3)
226
+ // and the returned vector is the unit-normalized form of [0.1, 0.2, 0.3].
227
+ const v = emb.embed("x");
228
+ assert.equal(v.length, 3);
229
+ const norm = Math.sqrt(0.1 ** 2 + 0.2 ** 2 + 0.3 ** 2);
230
+ assert.ok(Math.abs(v[0] - 0.1 / norm) < 1e-9);
231
+ assert.ok(Math.abs(v[1] - 0.2 / norm) < 1e-9);
232
+ assert.ok(Math.abs(v[2] - 0.3 / norm) < 1e-9);
233
+ } finally {
234
+ proc.kill();
235
+ }
236
+ });
237
+
238
+ // --- cleanup ---------------------------------------------------------------
239
+
240
+ test("Sprint 12 cleanup", () => {
241
+ rmSync(baseTmp, { recursive: true, force: true });
242
+ });