pi-mega-compact 0.8.22 → 0.8.24

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 (63) hide show
  1. package/LICENSE +6 -2
  2. package/README.md +1 -1
  3. package/dist/extensions/dashboard-server/api-contracts/endpoints.js +8 -0
  4. package/dist/extensions/dashboard-server/api-contracts/game-types.js +7 -0
  5. package/dist/extensions/mega-runtime/append-event.js +24 -0
  6. package/dist/extensions/mega-runtime/bind-repo.js +65 -0
  7. package/dist/extensions/mega-runtime/capture-model.js +87 -0
  8. package/dist/extensions/mega-runtime/dashboard-snapshot.js +118 -0
  9. package/dist/extensions/mega-runtime/effects.js +86 -0
  10. package/dist/extensions/mega-runtime/engine-view.js +11 -0
  11. package/dist/extensions/mega-runtime/game-state.js +116 -0
  12. package/dist/extensions/mega-runtime/get-state-dir.js +10 -0
  13. package/dist/extensions/mega-runtime/perf.js +49 -0
  14. package/dist/extensions/mega-runtime/pressure-getters.js +64 -0
  15. package/dist/extensions/mega-runtime/render-widget.js +17 -0
  16. package/dist/extensions/mega-runtime/reset-runtime.js +50 -0
  17. package/dist/extensions/mega-runtime/runtime-helpers.js +73 -0
  18. package/dist/extensions/mega-runtime/runtime-snapshot.js +204 -0
  19. package/dist/extensions/mega-runtime/runtime.js +352 -0
  20. package/dist/extensions/mega-runtime/snapshot.js +142 -0
  21. package/dist/extensions/mega-runtime/state.js +5 -1151
  22. package/dist/extensions/mega-runtime/status.js +11 -0
  23. package/dist/extensions/mega-runtime/widget-ansi.js +207 -0
  24. package/dist/extensions/mega-runtime/widget-types.js +8 -0
  25. package/dist/extensions/mega-runtime/widget.js +15 -204
  26. package/dist/extensions/openclaw-mega-compact.js +291 -0
  27. package/dist/src/dedup/raptor/multilevel.js +172 -0
  28. package/dist/src/dedup/raptor/multilevel.test.js +203 -0
  29. package/dist/src/dedup/raptor/retrieval.js +1 -1
  30. package/dist/src/minilm.js +92 -0
  31. package/dist/src/wordpiece.js +129 -0
  32. package/extensions/dashboard-client/dist/assets/index-D_WtU2TV.js.map +1 -1
  33. package/extensions/dashboard-server/api-contracts/endpoints.ts +30 -155
  34. package/extensions/dashboard-server/api-contracts/game-types.ts +172 -0
  35. package/extensions/mega-runtime/DECOMPOSITION.md +180 -0
  36. package/extensions/mega-runtime/README.md +38 -0
  37. package/extensions/mega-runtime/append-event.ts +40 -0
  38. package/extensions/mega-runtime/bind-repo.ts +81 -0
  39. package/extensions/mega-runtime/capture-model.ts +101 -0
  40. package/extensions/mega-runtime/dashboard-snapshot.ts +173 -0
  41. package/extensions/mega-runtime/effects.ts +129 -0
  42. package/extensions/mega-runtime/engine-view.ts +17 -0
  43. package/extensions/mega-runtime/game-state.ts +149 -0
  44. package/extensions/mega-runtime/get-state-dir.ts +19 -0
  45. package/extensions/mega-runtime/perf.ts +60 -0
  46. package/extensions/mega-runtime/pressure-getters.ts +96 -0
  47. package/extensions/mega-runtime/render-widget.ts +41 -0
  48. package/extensions/mega-runtime/reset-runtime.ts +80 -0
  49. package/extensions/mega-runtime/runtime-helpers.ts +119 -0
  50. package/extensions/mega-runtime/runtime-snapshot.ts +289 -0
  51. package/extensions/mega-runtime/runtime.ts +437 -0
  52. package/extensions/mega-runtime/snapshot.ts +230 -0
  53. package/extensions/mega-runtime/state.ts +5 -1268
  54. package/extensions/mega-runtime/status.ts +26 -0
  55. package/extensions/mega-runtime/widget-ansi.ts +217 -0
  56. package/extensions/mega-runtime/widget-types.ts +80 -0
  57. package/extensions/mega-runtime/widget.ts +34 -285
  58. package/package.json +2 -2
  59. package/src/dedup/raptor/multilevel.test.ts +278 -0
  60. package/src/dedup/raptor/multilevel.ts +246 -0
  61. package/src/dedup/raptor/retrieval.ts +1 -1
  62. package/dist/extensions/dashboard-client/src/hooks/useApi.js +0 -51
  63. package/dist/extensions/dashboard-client/src/hooks/useSSE.js +0 -63
@@ -0,0 +1,291 @@
1
+ /**
2
+ * openclaw-mega-compact — OpenClaw plugin adapter for the pi-mega-compact engine.
3
+ *
4
+ * Wires the pi-agnostic Trident engine (src/) into OpenClaw's plugin lifecycle:
5
+ * - Registers a CompactionProvider that replaces the built-in summarizeInStages.
6
+ * - Exposes `mega_status` and `mega_recall` tools for on-demand inspection.
7
+ * - Hooks into `before_compaction` / `after_compaction` for diagnostics.
8
+ *
9
+ * Design constraints:
10
+ * - NO imports from `@earendil-works/pi-coding-agent` or pi-agent-core.
11
+ * - The engine core (src/) is pi-agnostic; this file is the sole OpenClaw boundary.
12
+ * - No network at runtime — everything is local (stores + extractive summarizer).
13
+ */
14
+ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
15
+ import { compactSession, setDefaultStore, } from "../src/engine.js";
16
+ import { recallAndInline } from "../src/recall.js";
17
+ import { VectorStore } from "../src/vectorStore.js";
18
+ // ---------------------------------------------------------------------------
19
+ // Constants
20
+ // ---------------------------------------------------------------------------
21
+ const PLUGIN_ID = "mega-compact";
22
+ const PLUGIN_LABEL = "Mega Compact (Trident)";
23
+ /** Default state directory for vector store persistence. */
24
+ const STATE_DIR = process.env.MEGA_COMPACT_STATE_DIR ?? undefined;
25
+ /** Minimum messages before we bother compacting. */
26
+ const MIN_MESSAGES_FOR_COMPACT = 6;
27
+ // ---------------------------------------------------------------------------
28
+ // Message conversion — OpenClaw unknown[] → EngineMessage[]
29
+ // ---------------------------------------------------------------------------
30
+ /**
31
+ * Best-effort conversion from OpenClaw's opaque message array to our
32
+ * EngineMessage shape. OpenClaw messages are typed as `unknown[]` so we
33
+ * handle whatever shape comes through gracefully.
34
+ */
35
+ function toEngineMessages(messages) {
36
+ return messages.map((msg) => {
37
+ if (!msg || typeof msg !== "object") {
38
+ // Primitive fallback — treat as custom text.
39
+ return {
40
+ role: "custom",
41
+ text: String(msg ?? ""),
42
+ };
43
+ }
44
+ const m = msg;
45
+ const role = typeof m.role === "string" ? m.role : "custom";
46
+ // Normalize role to one of our four engine roles.
47
+ let engineRole;
48
+ switch (role) {
49
+ case "user":
50
+ engineRole = "user";
51
+ break;
52
+ case "assistant":
53
+ engineRole = "assistant";
54
+ break;
55
+ case "tool":
56
+ case "function":
57
+ engineRole = "tool";
58
+ break;
59
+ default:
60
+ engineRole = "custom";
61
+ break;
62
+ }
63
+ // Extract text content from common message shapes.
64
+ const text = typeof m.content === "string"
65
+ ? m.content
66
+ : typeof m.text === "string"
67
+ ? m.text
68
+ : Array.isArray(m.content)
69
+ ? m.content
70
+ .filter((part) => part.type === "text" && typeof part.text === "string")
71
+ .map((part) => part.text)
72
+ .join("\n")
73
+ : "";
74
+ // Preserve tool metadata when present.
75
+ const toolName = typeof m.name === "string"
76
+ ? m.name
77
+ : typeof m.toolName === "string"
78
+ ? m.toolName
79
+ : undefined;
80
+ const input = typeof m.input === "string"
81
+ ? m.input
82
+ : typeof m.arguments === "string"
83
+ ? m.arguments
84
+ : m.arguments !== undefined
85
+ ? JSON.stringify(m.arguments)
86
+ : undefined;
87
+ const output = typeof m.output === "string"
88
+ ? m.output
89
+ : engineRole === "tool" && typeof m.content === "string"
90
+ ? m.content
91
+ : undefined;
92
+ return { role: engineRole, text, toolName, input, output };
93
+ });
94
+ }
95
+ // ---------------------------------------------------------------------------
96
+ // Compaction provider
97
+ // ---------------------------------------------------------------------------
98
+ function createCompactionProvider(store) {
99
+ return {
100
+ id: PLUGIN_ID,
101
+ label: PLUGIN_LABEL,
102
+ async summarize({ messages, signal, compressionRatio, }) {
103
+ // Abort check — bail early if the caller cancelled.
104
+ if (signal?.aborted) {
105
+ throw new DOMException("Aborted", "AbortError");
106
+ }
107
+ const engineMessages = toEngineMessages(messages);
108
+ // Nothing meaningful to compact.
109
+ if (engineMessages.length < MIN_MESSAGES_FOR_COMPACT) {
110
+ return "";
111
+ }
112
+ // Map compression ratio → keepFrom boundary.
113
+ // compressionRatio=0.5 means "compact the oldest 50%".
114
+ // Default to compacting the oldest half if not specified.
115
+ const ratio = compressionRatio ?? 0.5;
116
+ const keepFrom = Math.max(MIN_MESSAGES_FOR_COMPACT, Math.floor(engineMessages.length * (1 - ratio)));
117
+ // Abort check after conversion (conversion is cheap but check anyway).
118
+ if (signal?.aborted) {
119
+ throw new DOMException("Aborted", "AbortError");
120
+ }
121
+ const sessionId = `openclaw-${Date.now()}`;
122
+ const input = {
123
+ sessionId,
124
+ messages: engineMessages,
125
+ keepFrom,
126
+ };
127
+ const result = compactSession(input, store);
128
+ if (result.skipped) {
129
+ return "";
130
+ }
131
+ return result.summary;
132
+ },
133
+ };
134
+ }
135
+ // ---------------------------------------------------------------------------
136
+ // Plugin entry
137
+ // ---------------------------------------------------------------------------
138
+ export default definePluginEntry({
139
+ id: PLUGIN_ID,
140
+ name: "Mega Compact",
141
+ description: "Layered, local, vector-backed context compressor (Trident engine) for OpenClaw compaction.",
142
+ register(api) {
143
+ const logger = api.logger;
144
+ // Resolve state directory — prefer plugin config override.
145
+ const pluginCfg = (api.pluginConfig ?? {});
146
+ const stateDir = typeof pluginCfg.stateDir === "string" && pluginCfg.stateDir.length > 0
147
+ ? pluginCfg.stateDir
148
+ : STATE_DIR;
149
+ // Initialize vector store.
150
+ let store;
151
+ try {
152
+ store = new VectorStore({ stateDir });
153
+ setDefaultStore(store);
154
+ logger.info?.(`${PLUGIN_ID}: vector store initialized (stateDir=${stateDir ?? "default"})`);
155
+ }
156
+ catch (err) {
157
+ logger.error?.(`${PLUGIN_ID}: failed to init vector store:`, err);
158
+ return; // Hard bail — no point registering if store is broken.
159
+ }
160
+ // -----------------------------------------------------------------------
161
+ // Register compaction provider
162
+ // -----------------------------------------------------------------------
163
+ const provider = createCompactionProvider(store);
164
+ api.registerCompactionProvider(provider);
165
+ logger.info?.(`${PLUGIN_ID}: registered compaction provider "${provider.id}"`);
166
+ // -----------------------------------------------------------------------
167
+ // Hooks — before / after compaction diagnostics
168
+ // -----------------------------------------------------------------------
169
+ api.registerHook({
170
+ event: "before_compaction",
171
+ handler: async (ctx) => {
172
+ const msgCount = Array.isArray(ctx?.messages) ? ctx.messages.length : 0;
173
+ logger.info?.(`${PLUGIN_ID}: before_compaction — ${msgCount} messages in scope`);
174
+ },
175
+ });
176
+ api.registerHook({
177
+ event: "after_compaction",
178
+ handler: async (ctx) => {
179
+ const summaryLen = typeof ctx?.summary === "string" ? ctx.summary.length : 0;
180
+ logger.info?.(`${PLUGIN_ID}: after_compaction — summary ${summaryLen} chars`);
181
+ },
182
+ });
183
+ // -----------------------------------------------------------------------
184
+ // Tool: mega_status
185
+ // -----------------------------------------------------------------------
186
+ api.registerTool({
187
+ name: "mega_status",
188
+ description: "Show the current status of the mega-compact engine: vector store stats, checkpoint count, and recent compaction activity.",
189
+ parameters: {
190
+ type: "object",
191
+ properties: {
192
+ sessionId: {
193
+ type: "string",
194
+ description: "Optional session ID to scope stats to.",
195
+ },
196
+ },
197
+ additionalProperties: false,
198
+ },
199
+ handler: async (args) => {
200
+ const sessionId = args?.sessionId ?? "global";
201
+ try {
202
+ const stats = store.stats(sessionId);
203
+ const parts = [
204
+ `**Mega Compact Status**`,
205
+ `Session: ${sessionId}`,
206
+ `Checkpoints: ${stats.checkpointCount}`,
207
+ `Total tokens saved: ${stats.totalTokenEstimate}`,
208
+ `Last checkpoint: ${stats.lastCheckpointId ?? "—"}`,
209
+ `Injected count: ${stats.injectedCount}`,
210
+ `Dedup hit rate: ${(stats.dedupHitRate * 100).toFixed(0)}%`,
211
+ ];
212
+ if (stats.lastSummary) {
213
+ parts.push(`\nLast summary (truncated):\n ${stats.lastSummary.slice(0, 120).replace(/\n/g, " ")}…`);
214
+ }
215
+ return { content: [{ type: "text", text: parts.join("\n") }] };
216
+ }
217
+ catch (err) {
218
+ return {
219
+ content: [{ type: "text", text: `Error reading mega-compact status: ${err}` }],
220
+ isError: true,
221
+ };
222
+ }
223
+ },
224
+ });
225
+ // -----------------------------------------------------------------------
226
+ // Tool: mega_recall
227
+ // -----------------------------------------------------------------------
228
+ api.registerTool({
229
+ name: "mega_recall",
230
+ description: "Recall and inline relevant context from the mega-compact vector store for the current session.",
231
+ parameters: {
232
+ type: "object",
233
+ properties: {
234
+ sessionId: {
235
+ type: "string",
236
+ description: "Session ID to recall context for.",
237
+ },
238
+ query: {
239
+ type: "string",
240
+ description: "Natural language query for relevant context.",
241
+ },
242
+ limit: {
243
+ type: "number",
244
+ description: "Max checkpoints to recall (default 3).",
245
+ },
246
+ },
247
+ required: ["sessionId", "query"],
248
+ additionalProperties: false,
249
+ },
250
+ handler: async (args) => {
251
+ const { sessionId, query, limit } = args;
252
+ if (!sessionId || !query) {
253
+ return {
254
+ content: [{ type: "text", text: "Both `sessionId` and `query` are required." }],
255
+ isError: true,
256
+ };
257
+ }
258
+ try {
259
+ const result = recallAndInline({ sessionId, query, limit: limit ?? 3, source: "command", skipInjected: false }, store);
260
+ if (result.toInject.length === 0) {
261
+ return {
262
+ content: [{ type: "text", text: "No relevant context found in the mega-compact store." }],
263
+ };
264
+ }
265
+ const parts = [
266
+ `**Recalled ${result.toInject.length} checkpoint(s):**`,
267
+ ...result.report,
268
+ "",
269
+ "---",
270
+ result.block,
271
+ ];
272
+ return { content: [{ type: "text", text: parts.join("\n") }] };
273
+ }
274
+ catch (err) {
275
+ return {
276
+ content: [{ type: "text", text: `Error during mega-recall: ${err}` }],
277
+ isError: true,
278
+ };
279
+ }
280
+ },
281
+ });
282
+ // -----------------------------------------------------------------------
283
+ // Cleanup on shutdown
284
+ // -----------------------------------------------------------------------
285
+ api.on("shutdown", () => {
286
+ logger.info?.(`${PLUGIN_ID}: shutting down — clearing default store`);
287
+ setDefaultStore(undefined);
288
+ });
289
+ logger.info?.(`${PLUGIN_ID}: plugin registered (tools: mega_status, mega_recall)`);
290
+ },
291
+ });
@@ -0,0 +1,172 @@
1
+ /**
2
+ * multilevel.ts — Multi-level RAPTOR retrieval engine (S42A).
3
+ *
4
+ * Upgrades the RAPTOR recall path from flat (leaf-only) to multi-level
5
+ * retrieval across the entire hierarchical tree. Searches ALL levels with
6
+ * configurable level weights, supports leaf expansion for cluster hits,
7
+ * and deduplicates overlapping results.
8
+ *
9
+ * PREVENT-PI-004: pure in-process math (cosine, BFS, extractive). No network.
10
+ * PREVENT-PI-001: produces SearchHit[] that feed into recallAndInline() —
11
+ * affects which checkpoints are recalled, not how messages are dropped.
12
+ */
13
+ import { cosineSimilarity } from "../../embedder.js";
14
+ import { mmrRerank } from "../mmr.js";
15
+ import { leafDescendants } from "./retrieval.js";
16
+ const DEFAULT_LEVEL_WEIGHTS = [1.0, 0.9, 0.8, 0.7, 0.5];
17
+ // ── S42A-2: Level-weighted scoring ─────────────────────────────────────────
18
+ /**
19
+ * Score all RAPTOR tree nodes by cosine similarity to the query, then apply
20
+ * level-specific weights. Returns hits sorted by weighted score descending.
21
+ *
22
+ * Level weights: leaves (level 0) get weight 1.0, level 1 gets 0.9, etc.
23
+ * This ensures detailed leaves score highest while still surfacing higher-level
24
+ * summaries when they're highly relevant.
25
+ */
26
+ export function scoreTreeLevels(query, tree, opts) {
27
+ const { embedder } = opts;
28
+ const weights = opts.levelWeights ?? DEFAULT_LEVEL_WEIGHTS;
29
+ const qv = embedder.embed(query);
30
+ const hits = [];
31
+ // 1. Score all internal (summary) nodes.
32
+ for (const node of tree.nodes.values()) {
33
+ const rawScore = cosineSimilarity(qv, node.embedding);
34
+ const levelWeight = weights[Math.min(node.level, weights.length - 1)];
35
+ hits.push({
36
+ nodeId: node.id,
37
+ level: node.level,
38
+ score: rawScore * levelWeight,
39
+ rawScore,
40
+ isLeaf: false,
41
+ leafIds: node.children,
42
+ summary: node.summary,
43
+ embedding: node.embedding,
44
+ });
45
+ }
46
+ // 2. Score leaf nodes. Leaf ids are not in tree.nodes — they are children
47
+ // referenced by internal nodes. Each leaf's embedding is the level-0
48
+ // parent node that wraps it (same approach as stagedExpansion:95–102).
49
+ const seenLeaves = new Set();
50
+ for (const node of tree.nodes.values()) {
51
+ for (const leafId of node.children) {
52
+ if (seenLeaves.has(leafId) || tree.nodes.has(leafId))
53
+ continue;
54
+ seenLeaves.add(leafId);
55
+ const rawScore = cosineSimilarity(qv, node.embedding);
56
+ const leafWeight = weights[0];
57
+ hits.push({
58
+ nodeId: leafId,
59
+ level: 0,
60
+ score: rawScore * leafWeight,
61
+ rawScore,
62
+ isLeaf: true,
63
+ leafIds: [leafId],
64
+ summary: "", // leaves have no summary — they are raw checkpoint ids
65
+ embedding: node.embedding,
66
+ });
67
+ }
68
+ }
69
+ hits.sort((a, b) => b.score - a.score);
70
+ return hits;
71
+ }
72
+ // ── S42A-3: Leaf expansion ─────────────────────────────────────────────────
73
+ /**
74
+ * Given a set of cluster-level hits, expand each one to include its leaf
75
+ * descendants. Deduplicates: if a leaf is already present as a direct hit,
76
+ * it is not duplicated. Returns the merged set (original hits + expanded leaves).
77
+ */
78
+ export function expandLeafDescendants(hits, tree, maxPerCluster, _embedder, queryVector, levelWeights) {
79
+ const weights = levelWeights ?? DEFAULT_LEVEL_WEIGHTS;
80
+ const existingIds = new Set(hits.map((h) => h.nodeId));
81
+ const expanded = [];
82
+ for (const hit of hits) {
83
+ if (hit.isLeaf) {
84
+ expanded.push(hit);
85
+ continue;
86
+ }
87
+ // Get all leaf descendants for this cluster node.
88
+ const node = tree.nodes.get(hit.nodeId);
89
+ if (!node) {
90
+ expanded.push(hit);
91
+ continue;
92
+ }
93
+ const rawLeafIds = leafDescendants(node, tree);
94
+ // Sort by cosine similarity to query, cap at maxPerCluster.
95
+ const leafHits = rawLeafIds
96
+ .map((lid) => {
97
+ // Leaf embedding = its nearest internal parent's embedding.
98
+ const parent = [...tree.nodes.values()].find((n) => n.children.includes(lid));
99
+ const sim = parent
100
+ ? cosineSimilarity(queryVector, parent.embedding)
101
+ : 0;
102
+ return { lid, sim, parent };
103
+ })
104
+ .sort((a, b) => b.sim - a.sim)
105
+ .slice(0, maxPerCluster)
106
+ .filter((l) => !existingIds.has(l.lid))
107
+ .map((l) => {
108
+ existingIds.add(l.lid);
109
+ const rawScore = l.sim;
110
+ return {
111
+ nodeId: l.lid,
112
+ level: 0,
113
+ score: rawScore * weights[0],
114
+ rawScore,
115
+ isLeaf: true,
116
+ leafIds: [l.lid],
117
+ summary: "",
118
+ embedding: l.parent?.embedding ?? hit.embedding,
119
+ };
120
+ });
121
+ expanded.push(hit, ...leafHits);
122
+ }
123
+ return expanded;
124
+ }
125
+ // ── S42A-4: Result dedup ───────────────────────────────────────────────────
126
+ /**
127
+ * Deduplicate hits: if both a cluster node and its leaf children appear in
128
+ * results, remove the cluster hit (leaves provide more specific context).
129
+ * If no leaves are in the set, keep the cluster hit (it provides the abstract view).
130
+ */
131
+ export function deduplicateMultilevelHits(hits) {
132
+ const leafIds = new Set(hits.filter((h) => h.isLeaf).map((h) => h.nodeId));
133
+ return hits.filter((h) => {
134
+ if (h.isLeaf)
135
+ return true;
136
+ // Cluster hit: keep only if none of its leaf children are present.
137
+ return !h.leafIds.some((lid) => leafIds.has(lid));
138
+ });
139
+ }
140
+ // ── S42A-5: Top-level pipeline ─────────────────────────────────────────────
141
+ /**
142
+ * Full multi-level retrieval pipeline: score → expand → dedup → MMR → top-K.
143
+ * Drop-in replacement for `stagedExpansion()` in the RAPTOR recall path.
144
+ */
145
+ export function multilevelRetrieval(query, tree, opts) {
146
+ if (!tree.rootId)
147
+ return [];
148
+ const { embedder } = opts;
149
+ const weights = opts.levelWeights ?? DEFAULT_LEVEL_WEIGHTS;
150
+ const leafExp = opts.leafExpansion !== false; // default true
151
+ const maxLeafExp = opts.maxLeafExpansion ?? 10;
152
+ const k = opts.k ?? 5;
153
+ const lambda = opts.mmrLambda ?? 0.5;
154
+ const qv = embedder.embed(query);
155
+ // 1. Score all nodes with level weights.
156
+ const scored = scoreTreeLevels(query, tree, { embedder, levelWeights: weights });
157
+ // 2. Top-N candidates for MMR diversity window.
158
+ const topN = scored.slice(0, k * 3);
159
+ // 3. Leaf expansion (optional).
160
+ const expanded = leafExp
161
+ ? expandLeafDescendants(topN, tree, maxLeafExp, embedder, qv, weights)
162
+ : topN;
163
+ // 4. Dedup: remove cluster hits when leaf children are present.
164
+ const deduped = deduplicateMultilevelHits(expanded);
165
+ // 5. MMR rerank to k.
166
+ const mmrItems = deduped.map((h) => ({
167
+ item: h,
168
+ vector: h.embedding,
169
+ relevance: h.score,
170
+ }));
171
+ return mmrRerank(mmrItems, k, lambda);
172
+ }
@@ -0,0 +1,203 @@
1
+ /**
2
+ * multilevel.test.ts — hermetic unit tests for S42A multi-level RAPTOR retrieval.
3
+ *
4
+ * Tests the scoreTreeLevels → expandLeafDescendants → deduplicateMultilevelHits
5
+ * → multilevelRetrieval pipeline. No network, no live store — uses TrigramEmbedder
6
+ * and synthetic RaptorTrees built from makeLeaves().
7
+ */
8
+ import { test } from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { TrigramEmbedder } from "../../embedder.js";
11
+ import { buildRaptorTree } from "./tree.js";
12
+ import { scoreTreeLevels, expandLeafDescendants, deduplicateMultilevelHits, multilevelRetrieval, } from "./multilevel.js";
13
+ function msg(text) {
14
+ return { role: "user", text };
15
+ }
16
+ /** Build N distinct leaves with deterministic content. */
17
+ function makeLeaves(n, embedder = new TrigramEmbedder()) {
18
+ const leaves = [];
19
+ for (let i = 0; i < n; i++) {
20
+ const text = `topic ${i % 7}: the module ${i} validated the session token and refreshed the cache for region ${i}`;
21
+ leaves.push({
22
+ id: `leaf_${i}`,
23
+ messages: [msg(text)],
24
+ sourceText: text,
25
+ embedding: embedder.embed(text),
26
+ });
27
+ }
28
+ return leaves;
29
+ }
30
+ // ── test: scoreTreeLevels returns nodes at all levels ────────────────────────
31
+ test("scoreTreeLevels returns results at multiple tree levels", () => {
32
+ const embedder = new TrigramEmbedder();
33
+ const leaves = makeLeaves(50);
34
+ const tree = buildRaptorTree(leaves, { embedder, clustersPerLevel: 4 });
35
+ const hits = scoreTreeLevels("the auth module validates the session token", tree, {
36
+ embedder,
37
+ });
38
+ assert.ok(hits.length > 0, "should return hits");
39
+ // Should include both leaf (level 0) and cluster (level ≥ 1) hits.
40
+ const levels = new Set(hits.map((h) => h.level));
41
+ assert.ok(levels.has(0), "should have leaf-level hits");
42
+ if (tree.levels > 1) {
43
+ const hasCluster = [...levels].some((l) => l >= 1);
44
+ assert.ok(hasCluster, "should have cluster-level hits for multi-level tree");
45
+ }
46
+ // All hits should have valid scores.
47
+ for (const h of hits) {
48
+ assert.ok(h.score >= 0 && h.score <= 1, `score ${h.score} out of range`);
49
+ assert.ok(h.rawScore >= 0 && h.rawScore <= 1, `rawScore ${h.rawScore} out of range`);
50
+ assert.ok(h.score <= h.rawScore, "weighted score <= raw score (level weights ≤ 1)");
51
+ }
52
+ // Hits should be sorted by score descending.
53
+ for (let i = 1; i < hits.length; i++) {
54
+ assert.ok(hits[i - 1].score >= hits[i].score, `hits not sorted: hit[${i - 1}].score=${hits[i - 1].score} < hit[${i}].score=${hits[i].score}`);
55
+ }
56
+ });
57
+ // ── test: level weights affect scoring ───────────────────────────────────────
58
+ test("level weights shift scores: higher weight for level → higher weighted score", () => {
59
+ const embedder = new TrigramEmbedder();
60
+ const leaves = makeLeaves(50);
61
+ const tree = buildRaptorTree(leaves, { embedder, clustersPerLevel: 4 });
62
+ const query = "the auth module validates the session token";
63
+ // Uniform weights: all levels scored equally.
64
+ const uniform = scoreTreeLevels(query, tree, {
65
+ embedder,
66
+ levelWeights: [1.0, 1.0, 1.0, 1.0, 1.0],
67
+ });
68
+ // Penalized weights: higher levels penalized.
69
+ const penalized = scoreTreeLevels(query, tree, {
70
+ embedder,
71
+ levelWeights: [1.0, 0.1, 0.1, 0.1, 0.1],
72
+ });
73
+ // With penalized weights, cluster-level hits should score lower.
74
+ const clusterUniform = uniform.filter((h) => h.level >= 1);
75
+ const clusterPenalized = penalized.filter((h) => h.level >= 1);
76
+ if (clusterUniform.length > 0 && clusterPenalized.length > 0) {
77
+ const avgUniform = clusterUniform.reduce((s, h) => s + h.score, 0) / clusterUniform.length;
78
+ const avgPenalized = clusterPenalized.reduce((s, h) => s + h.score, 0) / clusterPenalized.length;
79
+ assert.ok(avgPenalized < avgUniform, `penalized cluster avg (${avgPenalized.toFixed(3)}) should be < uniform (${avgUniform.toFixed(3)})`);
80
+ }
81
+ });
82
+ // ── test: expandLeafDescendants adds leaf hits ──────────────────────────────
83
+ test("expandLeafDescendants adds leaf descendants for cluster hits", () => {
84
+ const embedder = new TrigramEmbedder();
85
+ const leaves = makeLeaves(50);
86
+ const tree = buildRaptorTree(leaves, { embedder, clustersPerLevel: 4 });
87
+ const query = "the auth module validates the session token";
88
+ const scored = scoreTreeLevels(query, tree, { embedder });
89
+ const qv = embedder.embed(query);
90
+ // Take only cluster hits (level ≥ 1).
91
+ const clusterHits = scored.filter((h) => !h.isLeaf).slice(0, 3);
92
+ assert.ok(clusterHits.length > 0, "should have cluster hits");
93
+ const expanded = expandLeafDescendants(clusterHits, tree, 5, // maxPerCluster
94
+ embedder, qv);
95
+ // Expanded set should include leaf hits.
96
+ const leafHits = expanded.filter((h) => h.isLeaf);
97
+ assert.ok(leafHits.length > 0, "should have expanded leaf hits");
98
+ assert.ok(expanded.length > clusterHits.length, `expanded (${expanded.length}) should be > cluster hits (${clusterHits.length})`);
99
+ // No duplicate ids.
100
+ const ids = new Set(expanded.map((h) => h.nodeId));
101
+ assert.equal(ids.size, expanded.length, "no duplicate node ids");
102
+ });
103
+ // ── test: deduplicateMultilevelHits removes cluster when leaves present ─────
104
+ test("deduplicateMultilevelHits removes cluster hits when leaf children are present", () => {
105
+ const embedder = new TrigramEmbedder();
106
+ const leaves = makeLeaves(50);
107
+ const tree = buildRaptorTree(leaves, { embedder, clustersPerLevel: 4 });
108
+ const query = "the auth module validates the session token";
109
+ const scored = scoreTreeLevels(query, tree, { embedder });
110
+ // Take a cluster hit and its leaf children.
111
+ const cluster = scored.find((h) => !h.isLeaf);
112
+ assert.ok(cluster, "should have a cluster hit");
113
+ const leafChildren = cluster.leafIds.slice(0, 2).map((lid) => ({
114
+ nodeId: lid,
115
+ level: 0,
116
+ score: 0.5,
117
+ rawScore: 0.5,
118
+ isLeaf: true,
119
+ leafIds: [lid],
120
+ summary: "",
121
+ embedding: cluster.embedding,
122
+ }));
123
+ const mixed = [cluster, ...leafChildren];
124
+ const deduped = deduplicateMultilevelHits(mixed);
125
+ // Cluster should be removed because its leaf children are present.
126
+ assert.ok(!deduped.find((h) => h.nodeId === cluster.nodeId), "cluster hit should be removed when leaf children are present");
127
+ assert.equal(deduped.length, leafChildren.length, "only leaf hits remain");
128
+ });
129
+ test("deduplicateMultilevelHits keeps cluster hits when no leaf children present", () => {
130
+ const clusterHit = {
131
+ nodeId: "cluster_1",
132
+ level: 1,
133
+ score: 0.8,
134
+ rawScore: 0.8,
135
+ isLeaf: false,
136
+ leafIds: ["leaf_1", "leaf_2"],
137
+ summary: "summarized content",
138
+ embedding: [1, 0, 0],
139
+ };
140
+ const deduped = deduplicateMultilevelHits([clusterHit]);
141
+ assert.equal(deduped.length, 1, "cluster should be kept when no leaf children present");
142
+ assert.equal(deduped[0].nodeId, "cluster_1");
143
+ });
144
+ // ── test: multilevelRetrieval returns cluster + leaf mix ────────────────────
145
+ test("multilevelRetrieval returns a mix of cluster and leaf hits", () => {
146
+ const embedder = new TrigramEmbedder();
147
+ const leaves = makeLeaves(50);
148
+ const tree = buildRaptorTree(leaves, { embedder, clustersPerLevel: 4 });
149
+ const hits = multilevelRetrieval("the auth module validates the session token", tree, {
150
+ embedder,
151
+ k: 5,
152
+ leafExpansion: true,
153
+ maxLeafExpansion: 3,
154
+ });
155
+ assert.ok(hits.length > 0, "should return hits");
156
+ assert.ok(hits.length <= 5, "should respect k=5");
157
+ // Should include leaf hits.
158
+ const leafHits = hits.filter((h) => h.isLeaf);
159
+ assert.ok(leafHits.length > 0, "should include leaf hits");
160
+ // All hits should have valid properties.
161
+ for (const h of hits) {
162
+ assert.ok(h.nodeId, "hit should have nodeId");
163
+ assert.ok(typeof h.level === "number", "hit should have numeric level");
164
+ assert.ok(h.score >= 0, "hit score should be non-negative");
165
+ assert.ok(Array.isArray(h.leafIds), "hit should have leafIds array");
166
+ }
167
+ });
168
+ test("multilevelRetrieval respects k parameter", () => {
169
+ const embedder = new TrigramEmbedder();
170
+ const leaves = makeLeaves(100);
171
+ const tree = buildRaptorTree(leaves, { embedder, clustersPerLevel: 8 });
172
+ for (const k of [1, 3, 5, 10]) {
173
+ const hits = multilevelRetrieval("the auth module validates the session token", tree, {
174
+ embedder,
175
+ k,
176
+ });
177
+ assert.ok(hits.length <= k, `k=${k}: got ${hits.length} hits, should be ≤ ${k}`);
178
+ }
179
+ });
180
+ test("multilevelRetrieval returns empty for empty tree", () => {
181
+ const embedder = new TrigramEmbedder();
182
+ const emptyTree = { nodes: new Map(), rootId: null, levels: 0, timedOut: false };
183
+ const hits = multilevelRetrieval("any query", emptyTree, { embedder });
184
+ assert.deepEqual(hits, []);
185
+ });
186
+ test("multilevelRetrieval with leafExpansion=false skips leaf expansion", () => {
187
+ const embedder = new TrigramEmbedder();
188
+ const leaves = makeLeaves(50);
189
+ const tree = buildRaptorTree(leaves, { embedder, clustersPerLevel: 4 });
190
+ const hits = multilevelRetrieval("the auth module validates the session token", tree, {
191
+ embedder,
192
+ k: 5,
193
+ leafExpansion: false,
194
+ });
195
+ assert.ok(hits.length > 0, "should still return hits");
196
+ // With leaf expansion off and tree having multiple levels, we may get
197
+ // cluster hits that are NOT expanded. The mix depends on tree structure.
198
+ // Just verify we got valid results.
199
+ for (const h of hits) {
200
+ assert.ok(h.nodeId, "hit should have nodeId");
201
+ assert.ok(h.score >= 0, "hit score should be non-negative");
202
+ }
203
+ });
@@ -18,7 +18,7 @@ function isLeafId(id, tree) {
18
18
  return !tree.nodes.has(id);
19
19
  }
20
20
  /** All leaf (raw) ids reachable beneath a node via BFS. */
21
- function leafDescendants(node, tree) {
21
+ export function leafDescendants(node, tree) {
22
22
  const out = [];
23
23
  const queue = [node];
24
24
  while (queue.length) {