pi-mega-compact 0.8.21 → 0.8.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +6 -2
- package/README.md +1 -1
- package/dist/extensions/dashboard-server/dashboard-client-core.js +201 -0
- package/dist/extensions/dashboard-server/dashboard-client-game.js +241 -0
- package/dist/extensions/dashboard-server/dashboard-client-repos.js +212 -0
- package/dist/extensions/dashboard-server/dashboard-client.js +19 -0
- package/dist/extensions/dashboard-server/html.js +2 -621
- package/dist/extensions/dashboard-server/routes-core.js +62 -0
- package/dist/extensions/dashboard-server/routes-game.js +323 -0
- package/dist/extensions/dashboard-server/routes-repo.js +170 -0
- package/dist/extensions/dashboard-server/routes-sessions.js +159 -0
- package/dist/extensions/dashboard-server/routes.js +10 -0
- package/dist/extensions/dashboard-server/server.js +26 -623
- package/dist/extensions/mega-commands.js +4 -3
- package/dist/extensions/mega-events/agent-handlers.js +2 -1
- package/dist/extensions/mega-events/compact-handlers.js +26 -0
- package/dist/extensions/mega-events/session-handlers.js +2 -1
- package/dist/extensions/mega-pipeline/compact.js +3 -2
- package/dist/extensions/mega-runtime/state.js +7 -7
- package/dist/src/dedup/raptor/multilevel.js +172 -0
- package/dist/src/dedup/raptor/multilevel.test.js +203 -0
- package/dist/src/dedup/raptor/promote.test.js +5 -5
- package/dist/src/dedup/raptor/retrieval.js +1 -1
- package/dist/src/dedup/sprint12.test.js +7 -7
- package/dist/src/dedup-engine.test.js +29 -29
- package/dist/src/e2e.test.js +38 -38
- package/dist/src/engine.js +3 -3
- package/dist/src/engine.test.js +6 -6
- package/dist/src/importance.js +197 -0
- package/dist/src/importance.test.js +372 -0
- package/dist/src/ratio.bench.test.js +18 -18
- package/dist/src/recall.js +6 -5
- package/dist/src/recall.test.js +85 -27
- package/dist/src/sprint14.test.js +2 -2
- package/dist/src/store/migrate.test.js +5 -5
- package/dist/src/store/sprint10.test.js +5 -5
- package/dist/src/store/sqlite/global-index.js +5 -174
- package/dist/src/store/sqlite/global-sessions.js +190 -0
- package/dist/src/vector-read.js +168 -0
- package/dist/src/vector-search.js +191 -0
- package/dist/src/vectorStore.js +10 -297
- package/dist/src/vectorStore.test.js +32 -32
- package/extensions/dashboard-server/dashboard-client-core.ts +202 -0
- package/extensions/dashboard-server/dashboard-client-game.ts +242 -0
- package/extensions/dashboard-server/dashboard-client-repos.ts +213 -0
- package/extensions/dashboard-server/dashboard-client.ts +21 -0
- package/extensions/dashboard-server/html.ts +2 -621
- package/extensions/dashboard-server/routes-core.ts +113 -0
- package/extensions/dashboard-server/routes-game.ts +386 -0
- package/extensions/dashboard-server/routes-repo.ts +212 -0
- package/extensions/dashboard-server/routes-sessions.ts +195 -0
- package/extensions/dashboard-server/routes.ts +13 -0
- package/extensions/dashboard-server/server.ts +37 -700
- package/extensions/mega-commands.ts +4 -3
- package/extensions/mega-events/agent-handlers.ts +2 -1
- package/extensions/mega-events/compact-handlers.ts +28 -0
- package/extensions/mega-events/session-handlers.ts +2 -1
- package/extensions/mega-pipeline/compact.ts +3 -2
- package/extensions/mega-runtime/state.ts +7 -7
- package/extensions/openclaw-mega-compact.ts +2 -2
- package/package.json +2 -2
- package/src/dedup/raptor/multilevel.test.ts +278 -0
- package/src/dedup/raptor/multilevel.ts +246 -0
- package/src/dedup/raptor/promote.test.ts +5 -5
- package/src/dedup/raptor/retrieval.ts +1 -1
- package/src/dedup/sprint12.test.ts +7 -7
- package/src/dedup-engine.test.ts +30 -30
- package/src/e2e.test.ts +38 -38
- package/src/engine.test.ts +6 -6
- package/src/engine.ts +3 -3
- package/src/importance.test.ts +538 -0
- package/src/importance.ts +312 -0
- package/src/ratio.bench.test.ts +18 -18
- package/src/recall.test.ts +101 -29
- package/src/recall.ts +9 -9
- package/src/sprint14.test.ts +2 -2
- package/src/store/migrate.test.ts +5 -5
- package/src/store/sprint10.test.ts +5 -5
- package/src/store/sqlite/global-index.ts +18 -290
- package/src/store/sqlite/global-sessions.ts +291 -0
- package/src/vector-read.ts +237 -0
- package/src/vector-search.ts +231 -0
- package/src/vectorStore.test.ts +32 -32
- package/src/vectorStore.ts +29 -356
|
@@ -12,6 +12,7 @@ import { decompressSmart } from "../src/store/compression.js";
|
|
|
12
12
|
import { loadMetrics, fpRate, p95, defaultMetricsPath } from "../src/monitoring.js";
|
|
13
13
|
import { C, recentUserQuery } from "./mega-runtime.js";
|
|
14
14
|
import { runCompact, doRecall, doRecallAsync } from "./mega-pipeline.js";
|
|
15
|
+
import { vectorStats, vectorRepoStats, vectorDataInvariant } from "../src/vectorStore.js";
|
|
15
16
|
/** Resolve a checkpoint by id (or "recent"/"last") from this session's store. */
|
|
16
17
|
export function findCheckpoint(runtime, sid, ref) {
|
|
17
18
|
const all = listCheckpoints(sid, runtime.currentStateDir);
|
|
@@ -88,9 +89,9 @@ export function registerCommands(pi, runtime, config) {
|
|
|
88
89
|
const pct = usage?.percent != null ? `${usage.percent}%` : "n/a";
|
|
89
90
|
const tokens = usage?.tokens != null ? `${usage.tokens} tok` : "n/a";
|
|
90
91
|
const sid = normalizeSessionId(ctx.sessionManager.getSessionId());
|
|
91
|
-
const st = runtime.store
|
|
92
|
-
const repo = runtime.store
|
|
93
|
-
const di = runtime.store
|
|
92
|
+
const st = vectorStats(runtime.store, sid);
|
|
93
|
+
const repo = vectorRepoStats(runtime.store);
|
|
94
|
+
const di = vectorDataInvariant(runtime.store);
|
|
94
95
|
const fmtB = (b) => b >= 1_048_576 ? `${(b / 1_048_576).toFixed(1)} MiB` :
|
|
95
96
|
b >= 1024 ? `${(b / 1024).toFixed(1)} KiB` : `${b} B`;
|
|
96
97
|
// Real cost: tokens saved × the captured model's input rate (USD/token),
|
|
@@ -6,6 +6,7 @@ import { isMegaCache } from "../../src/game/scoring.js";
|
|
|
6
6
|
import { resolveRepoRoot } from "../mega-config.js";
|
|
7
7
|
import { classifyError } from "./error-classifier.js";
|
|
8
8
|
import { safeSendUserMessage } from "./send-safe.js";
|
|
9
|
+
import { vectorStats } from "../../src/vectorStore.js";
|
|
9
10
|
/** Register agent/turn tracking event handlers. */
|
|
10
11
|
export function registerAgentHandlers(pi, runtime, config) {
|
|
11
12
|
// ---- Agent tracking for real-time widget + status-line updates ---------
|
|
@@ -215,7 +216,7 @@ export function registerAgentHandlers(pi, runtime, config) {
|
|
|
215
216
|
try {
|
|
216
217
|
if (runtime.getCachedGameState().game_mode_on) {
|
|
217
218
|
const repo = resolveRepoRoot(ctx.cwd) ?? runtime.currentStateDir;
|
|
218
|
-
const st = runtime.store
|
|
219
|
+
const st = vectorStats(runtime.store, runtime.rt.sessionId);
|
|
219
220
|
const cachePct = st.dedupHitRate * 100;
|
|
220
221
|
const modelId = runtime.currentModel?.modelId ?? "unknown";
|
|
221
222
|
recordScore(runtime.currentStateDir, {
|
|
@@ -79,6 +79,32 @@ export function registerCompactHandlers(pi, runtime, config) {
|
|
|
79
79
|
});
|
|
80
80
|
if (!config.auto)
|
|
81
81
|
return {}; // let pi run its own native compaction
|
|
82
|
+
// S38.5: COMPACT-DEDUP RACE GUARD — pi's _runAutoCompaction fires
|
|
83
|
+
// session_before_compact from within the agent loop (after agent_end,
|
|
84
|
+
// _handlePostAgentRun → _checkCompaction → _runAutoCompaction). If a
|
|
85
|
+
// compaction just completed (within the cooldown window), pi's
|
|
86
|
+
// prepareCompaction may still see the last entry as a compaction but
|
|
87
|
+
// the extension's session_compact handler hasn't been called yet, OR
|
|
88
|
+
// the setTimeout deferred ctx.compact() from the agent_end handler is
|
|
89
|
+
// about to race. Skip the durable trim and let pi run its own compact
|
|
90
|
+
// (or no-op if the branch is already compacted). Matches the cooldown
|
|
91
|
+
// in agent-handlers.ts so both call sites are consistent.
|
|
92
|
+
const cooldownMs = config.raceGuardStrict ? 30_000 : 10_000;
|
|
93
|
+
const sinceCompact = Date.now() - (runtime.rt.lastNativeCompactAt ?? 0);
|
|
94
|
+
if (sinceCompact < cooldownMs) {
|
|
95
|
+
runtime.logger.info("before-compact-skip-recent", {
|
|
96
|
+
sessionId: runtime.rt.sessionId,
|
|
97
|
+
reason: event.reason,
|
|
98
|
+
sinceCompactMs: sinceCompact,
|
|
99
|
+
cooldownMs,
|
|
100
|
+
});
|
|
101
|
+
runtime.diagBeforeCompactSupplied++;
|
|
102
|
+
const fb = fallbackCompaction(event);
|
|
103
|
+
if (fb) {
|
|
104
|
+
return { compaction: fb.compaction };
|
|
105
|
+
}
|
|
106
|
+
return {};
|
|
107
|
+
}
|
|
82
108
|
try {
|
|
83
109
|
const result = driveNativeCompaction(event, runtime, config);
|
|
84
110
|
if (result && result.compaction.summary?.trim()) {
|
|
@@ -3,6 +3,7 @@ import { autoMaintain } from "../../src/store/sqlite.js";
|
|
|
3
3
|
import { recentUserQuery, WIDGET_KEY, } from "../mega-runtime.js";
|
|
4
4
|
import { doRecall, doRecallAsync, } from "../mega-pipeline.js";
|
|
5
5
|
import { recallMemoriesAndInline } from "../../src/recall.js";
|
|
6
|
+
import { vectorStats } from "../../src/vectorStore.js";
|
|
6
7
|
/** Register session lifecycle event handlers. */
|
|
7
8
|
export function registerSessionHandlers(pi, runtime, config) {
|
|
8
9
|
// Capture model/provider whenever it changes (drives real cost estimation).
|
|
@@ -26,7 +27,7 @@ export function registerSessionHandlers(pi, runtime, config) {
|
|
|
26
27
|
if (config.autoInline) {
|
|
27
28
|
const sid = normalizeSessionId(ctx.sessionManager.getSessionId());
|
|
28
29
|
const query = recentUserQuery(ctx);
|
|
29
|
-
if (query && runtime.store
|
|
30
|
+
if (query && vectorStats(runtime.store, sid).checkpointCount > 0) {
|
|
30
31
|
// S17: use the async variant on resume so cross-repo HNSW recall can
|
|
31
32
|
// augment when this repo's store is thin. session_start is an async-safe
|
|
32
33
|
// point (unlike the mid-turn context handler, which stays sync).
|
|
@@ -19,6 +19,7 @@ import { runRaptor } from "../../src/dedup/raptor/index.js";
|
|
|
19
19
|
import { loadDedupConfig } from "../../src/config/dedup.js";
|
|
20
20
|
import { upsertEmbedding as indexUpsertEmbedding } from "../../src/store/vectorIndex.js";
|
|
21
21
|
import { runMemoryReview } from "./memory-review.js";
|
|
22
|
+
import { vectorList } from "../../src/vectorStore.js";
|
|
22
23
|
/** Run the full compaction pipeline and persist a checkpoint. Returns the result. */
|
|
23
24
|
export function runCompact(pi, runtime, config, ctx, messages, opts = {}) {
|
|
24
25
|
runtime.bindRepo(ctx.cwd);
|
|
@@ -171,7 +172,7 @@ function doCompact(view, keepFrom, opts, sid, config, pi, ctx, runtime) {
|
|
|
171
172
|
if (config.raptorEnabled && !result.deduped) {
|
|
172
173
|
try {
|
|
173
174
|
const dd = loadDedupConfig();
|
|
174
|
-
const all = runtime.store
|
|
175
|
+
const all = vectorList(runtime.store, sid);
|
|
175
176
|
const leaves = all.map((cp) => ({
|
|
176
177
|
id: cp.checkpointId,
|
|
177
178
|
messages: [],
|
|
@@ -204,7 +205,7 @@ function doCompact(view, keepFrom, opts, sid, config, pi, ctx, runtime) {
|
|
|
204
205
|
// Non-fatal: a WASM init failure degrades to the sync scan silently.
|
|
205
206
|
if (!result.deduped) {
|
|
206
207
|
try {
|
|
207
|
-
const all = runtime.store
|
|
208
|
+
const all = vectorList(runtime.store, sid);
|
|
208
209
|
const latest = all.find((cp) => cp.checkpointId === result.checkpointId);
|
|
209
210
|
if (latest?.embedding) {
|
|
210
211
|
void indexUpsertEmbedding(runtime.currentStateDir, sid, latest.checkpointId, latest.embedding).catch(() => {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { join } from "node:path";
|
|
13
13
|
import { appendFileSync, mkdirSync } from "node:fs";
|
|
14
|
-
import { VectorStore } from "../../src/vectorStore.js";
|
|
14
|
+
import { VectorStore, vectorStats, vectorRepoStats, vectorDataInvariant } from "../../src/vectorStore.js";
|
|
15
15
|
import { toEngineMessages } from "../../src/adapt.js";
|
|
16
16
|
import { normalizeSessionId } from "../../src/store.js";
|
|
17
17
|
import { Logger } from "../../src/log.js";
|
|
@@ -306,8 +306,8 @@ export class MegaRuntime {
|
|
|
306
306
|
// never break the per-repo compaction path. Runs only on repo-switch
|
|
307
307
|
// (this branch), so it's infrequent — not per-context-event.
|
|
308
308
|
try {
|
|
309
|
-
const repo = this.store
|
|
310
|
-
const di = this.store
|
|
309
|
+
const repo = vectorRepoStats(this.store);
|
|
310
|
+
const di = vectorDataInvariant(this.store);
|
|
311
311
|
const root = key !== dir ? key : (resolveRepoRoot(cwd ?? dir) ?? dir);
|
|
312
312
|
upsertRepoRegistry({
|
|
313
313
|
repoRoot: root,
|
|
@@ -346,9 +346,9 @@ export class MegaRuntime {
|
|
|
346
346
|
return;
|
|
347
347
|
}
|
|
348
348
|
const perfT0 = performance.now();
|
|
349
|
-
const st = this.store
|
|
350
|
-
const repo = this.store
|
|
351
|
-
const di = this.store
|
|
349
|
+
const st = vectorStats(this.store, this.rt.sessionId);
|
|
350
|
+
const repo = vectorRepoStats(this.store);
|
|
351
|
+
const di = vectorDataInvariant(this.store);
|
|
352
352
|
// Live + store-wide cache-hit / compaction counters for the dashboard.
|
|
353
353
|
const ds = getDedupStats(this.currentStateDir);
|
|
354
354
|
const cacheHitsTotal = ds.deduped + getRecallInjected(this.currentStateDir);
|
|
@@ -492,7 +492,7 @@ export class MegaRuntime {
|
|
|
492
492
|
total: getCompactCount(this.currentStateDir),
|
|
493
493
|
},
|
|
494
494
|
timeSaved: {
|
|
495
|
-
compact: { sessionSec: sec(this.rt.tokensSaved), totalSec: sec(this.store
|
|
495
|
+
compact: { sessionSec: sec(this.rt.tokensSaved), totalSec: sec(vectorRepoStats(this.store).tokensSaved) },
|
|
496
496
|
cacheHit: { sessionSec: sec(this.rt.cacheHitTokens), totalSec: sec(cacheHitsTotalTokens) },
|
|
497
497
|
},
|
|
498
498
|
model,
|
|
@@ -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
|
+
});
|
|
@@ -11,7 +11,7 @@ import assert from "node:assert/strict";
|
|
|
11
11
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
12
12
|
import { tmpdir } from "node:os";
|
|
13
13
|
import { join } from "node:path";
|
|
14
|
-
import { VectorStore } from "../../vectorStore.js";
|
|
14
|
+
import { VectorStore, vectorList, vectorSearch } from "../../vectorStore.js";
|
|
15
15
|
import { runRaptor } from "./index.js";
|
|
16
16
|
import { compactSession } from "../../engine.js";
|
|
17
17
|
import { Logger } from "../../log.js";
|
|
@@ -35,10 +35,10 @@ test("Fix D: vectorStore.search serves a persisted RAPTOR tree (broader recall)"
|
|
|
35
35
|
}
|
|
36
36
|
// No tree yet → flat search only, returns hits, no RAPTOR coverage.
|
|
37
37
|
assert.equal(listRaptorNodes(SESS, stateDir).length, 0, "no tree initially");
|
|
38
|
-
const flat = s
|
|
38
|
+
const flat = vectorSearch(s, SESS, "alpha wire bootstrap", 3);
|
|
39
39
|
assert.ok(flat.length > 0, "flat search returns hits");
|
|
40
40
|
// Build + persist a RAPTOR tree for the session (mirrors runCompact refresh).
|
|
41
|
-
const all = s
|
|
41
|
+
const all = vectorList(s, SESS);
|
|
42
42
|
const leaves = all.map((cp) => ({
|
|
43
43
|
id: cp.checkpointId,
|
|
44
44
|
messages: [],
|
|
@@ -49,7 +49,7 @@ test("Fix D: vectorStore.search serves a persisted RAPTOR tree (broader recall)"
|
|
|
49
49
|
assert.ok(tree && listRaptorNodes(SESS, stateDir).length > 0, "tree persisted");
|
|
50
50
|
// With the tree live + RAPTOR_ENABLED, search still returns hits and now
|
|
51
51
|
// exercises the RAPTOR-served path without regression.
|
|
52
|
-
const withTree = s
|
|
52
|
+
const withTree = vectorSearch(s, SESS, "alpha wire bootstrap", 3);
|
|
53
53
|
assert.ok(withTree.length > 0, "search returns hits with RAPTOR promoted");
|
|
54
54
|
// Every returned hit is a real checkpoint in the session.
|
|
55
55
|
for (const h of withTree) {
|
|
@@ -60,7 +60,7 @@ test("Fix D: search still works for a session with <2 leaves (no tree)", () => {
|
|
|
60
60
|
const stateDir = join(baseTmp, `run-${counter++}`);
|
|
61
61
|
const s = new VectorStore({ dedupSim: 0.9, stateDir, config: raptorConfig() });
|
|
62
62
|
compactSession({ sessionId: SESS, messages: [msg("only one topic here"), msg("ok", "Edit")], keepFrom: 2, timestamp: 1 }, s);
|
|
63
|
-
const r = s
|
|
63
|
+
const r = vectorSearch(s, SESS, "only one topic", 3);
|
|
64
64
|
assert.ok(r.length > 0, "single-checkpoint search still works (no tree)");
|
|
65
65
|
assert.equal(listRaptorNodes(SESS, stateDir).length, 0, "no tree built for <2 leaves");
|
|
66
66
|
});
|
|
@@ -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) {
|
|
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
|
|
|
3
3
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
-
import { VectorStore, L2_ENABLED } from "../vectorStore.js";
|
|
6
|
+
import { VectorStore, L2_ENABLED, vectorSemDedup, vectorList, vectorSearch } from "../vectorStore.js";
|
|
7
7
|
import { mmrRerank } from "./mmr.js";
|
|
8
8
|
import { topK } from "./topk.js";
|
|
9
9
|
import { cosineSimilarity, defaultEmbedder } from "../embedder.js";
|
|
@@ -67,7 +67,7 @@ test("L2_ENABLED defaults true; search still returns hits", () => {
|
|
|
67
67
|
assert.equal(L2_ENABLED, true);
|
|
68
68
|
const s = store();
|
|
69
69
|
s.add({ sessionId: "sess_l2", summary: "investigated the parser", regionText: "investigated src/parser.ts and added a tokenizer", timestamp: 1 });
|
|
70
|
-
const hits = s
|
|
70
|
+
const hits = vectorSearch(s, "sess_l2", "src/parser.ts tokenizer", 3);
|
|
71
71
|
assert.ok(hits.length >= 1);
|
|
72
72
|
});
|
|
73
73
|
test("L2_ENABLED=false skips semantic tier but L0/L1 still work", () => {
|
|
@@ -106,14 +106,14 @@ test("semDedup marks redundant near-identical rows 'removed' and search excludes
|
|
|
106
106
|
{ id: "chkpt_001", text: "the cache stores parsed ast nodes for fast lookup", tok: 100 },
|
|
107
107
|
{ id: "chkpt_002", text: "the cache stores parsed ast nodes for fast lookup and reuse", tok: 900 },
|
|
108
108
|
]);
|
|
109
|
-
const removed = s
|
|
109
|
+
const removed = vectorSemDedup(s, "sess_sd", 0.85);
|
|
110
110
|
assert.equal(removed, 1);
|
|
111
|
-
const st = s
|
|
111
|
+
const st = vectorList(s, "sess_sd");
|
|
112
112
|
const dropped = st.find((c) => c.dedupStatus === "removed");
|
|
113
113
|
assert.ok(dropped);
|
|
114
114
|
assert.equal(dropped.checkpointId, "chkpt_001"); // lower tokenEstimate removed
|
|
115
115
|
// Search excludes the removed row (only one active remains).
|
|
116
|
-
const hits = s
|
|
116
|
+
const hits = vectorSearch(s, "sess_sd", "cache parsed ast nodes", 5);
|
|
117
117
|
assert.equal(hits.length, 1);
|
|
118
118
|
});
|
|
119
119
|
test("semDedup is idempotent (re-run removes nothing new)", () => {
|
|
@@ -123,8 +123,8 @@ test("semDedup is idempotent (re-run removes nothing new)", () => {
|
|
|
123
123
|
{ id: "chkpt_001", text: "identical region text for the dedup job now", tok: 100 },
|
|
124
124
|
{ id: "chkpt_002", text: "identical region text for the dedup job right now", tok: 200 },
|
|
125
125
|
]);
|
|
126
|
-
const first = s
|
|
127
|
-
const second = s
|
|
126
|
+
const first = vectorSemDedup(s, "sess_sd2", 0.85);
|
|
127
|
+
const second = vectorSemDedup(s, "sess_sd2", 0.85);
|
|
128
128
|
assert.equal(first, 1);
|
|
129
129
|
assert.equal(second, 0);
|
|
130
130
|
});
|