pi-mega-compact 0.4.5 → 0.4.7
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/dist/extensions/dashboard-server.js +450 -0
- package/dist/extensions/dashboard-server.test.js +111 -0
- package/dist/extensions/error-patterns.js +115 -0
- package/dist/extensions/mega-compact.js +821 -0
- package/dist/extensions/mega-compact.test.js +328 -0
- package/dist/extensions/openclaw-mega-compact.js +291 -0
- package/dist/src/adapt.js +106 -0
- package/dist/src/boundary.js +88 -0
- package/dist/src/boundary.test.js +53 -0
- package/dist/src/canary.js +118 -0
- package/dist/src/compact.js +250 -0
- package/dist/src/compact.test.js +78 -0
- package/dist/src/config/dedup.js +81 -0
- package/dist/src/config.js +12 -0
- package/dist/src/dedup/dedup.test.js +41 -0
- package/dist/src/dedup/digest.js +30 -0
- package/dist/src/dedup/l1-lsh.js +52 -0
- package/dist/src/dedup/l1-minhash.js +91 -0
- package/dist/src/dedup/l1-verify.js +54 -0
- package/dist/src/dedup/l1.test.js +50 -0
- package/dist/src/dedup/mmr.js +45 -0
- package/dist/src/dedup/normalize.js +39 -0
- package/dist/src/dedup/raptor/guardrails.js +83 -0
- package/dist/src/dedup/raptor/index.js +94 -0
- package/dist/src/dedup/raptor/kmeans.js +152 -0
- package/dist/src/dedup/raptor/raptor.test.js +205 -0
- package/dist/src/dedup/raptor/retrieval.js +81 -0
- package/dist/src/dedup/raptor/summarizer.js +85 -0
- package/dist/src/dedup/raptor/tree.js +177 -0
- package/dist/src/dedup/sprint12.test.js +219 -0
- package/dist/src/dedup/topk.js +60 -0
- package/dist/src/dedup-engine.test.js +447 -0
- package/dist/src/e2e.test.js +698 -0
- package/dist/src/embedder.js +102 -0
- package/dist/src/engine.js +139 -0
- package/dist/src/engine.test.js +111 -0
- package/dist/src/extractive.js +209 -0
- package/dist/src/extractive.test.js +130 -0
- package/dist/src/httpEmbedder.js +143 -0
- package/dist/src/log.js +47 -0
- package/dist/src/log.test.js +42 -0
- package/dist/src/minilm.js +92 -0
- package/dist/src/monitoring.js +131 -0
- package/dist/src/ratio.bench.test.js +897 -0
- package/dist/src/recall.integration.test.js +77 -0
- package/dist/src/recall.js +60 -0
- package/dist/src/recall.test.js +50 -0
- package/dist/src/sprint14.test.js +219 -0
- package/dist/src/store/backfill.js +189 -0
- package/dist/src/store/bloom.js +114 -0
- package/dist/src/store/compression.js +177 -0
- package/dist/src/store/compression.test.js +67 -0
- package/dist/src/store/integrity.js +44 -0
- package/dist/src/store/migrate.js +79 -0
- package/dist/src/store/migrate.test.js +139 -0
- package/dist/src/store/sprint10.test.js +186 -0
- package/dist/src/store/sqlite.js +574 -0
- package/dist/src/store.js +115 -0
- package/dist/src/store.test.js +142 -0
- package/dist/src/supersede.js +68 -0
- package/dist/src/supersede.test.js +36 -0
- package/dist/src/tokens.js +31 -0
- package/dist/src/types.js +8 -0
- package/dist/src/types.test.js +9 -0
- package/dist/src/vectorStore.js +465 -0
- package/dist/src/vectorStore.test.js +479 -0
- package/dist/src/wordpiece.js +129 -0
- package/extensions/mega-compact.ts +47 -11
- package/package.json +4 -2
- package/src/engine.ts +5 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* index.ts — RAPTOR orchestrator (Sprint 13, Phase 6).
|
|
3
|
+
*
|
|
4
|
+
* Entry point that turns a session's leaves into a hierarchical summary tree.
|
|
5
|
+
* Shadow mode (RAPTOR_SHADOW_MODE default true): the tree is BUILT and PERSISTED
|
|
6
|
+
* to raptor_nodes + logged, but is NOT used to serve retrieval. The live
|
|
7
|
+
* vectorStore.search path is untouched until Sprint 14 promotes RAPTOR.
|
|
8
|
+
*
|
|
9
|
+
* PREVENT-PI-004: no network here. Any Ollama call lives in summarizer.ts
|
|
10
|
+
* (localhost-only, annotated). This module is pure orchestration.
|
|
11
|
+
*/
|
|
12
|
+
import { defaultEmbedder } from "../../embedder.js";
|
|
13
|
+
import { buildRaptorTree } from "./tree.js";
|
|
14
|
+
import { stagedExpansion } from "./retrieval.js";
|
|
15
|
+
import { saveRaptorTree, listRaptorNodes } from "../../store/sqlite.js";
|
|
16
|
+
/** Shadow mode is on by default; set RAPTOR_SHADOW_MODE=false to serve live. */
|
|
17
|
+
export function isShadowMode() {
|
|
18
|
+
return process.env.RAPTOR_SHADOW_MODE !== "false";
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Build the RAPTOR tree for a session's leaves. Per shadow-mode rules, the tree
|
|
22
|
+
* is persisted + logged regardless; whether it is served is the caller's choice
|
|
23
|
+
* (Sprint 13: it is built but NOT injected into recallAndInline).
|
|
24
|
+
*
|
|
25
|
+
* Returns the built tree (in-memory) for eval/tests, and persists it to the
|
|
26
|
+
* store. Never throws — on any build error it logs and returns null.
|
|
27
|
+
*/
|
|
28
|
+
export function runRaptor(leaves, opts) {
|
|
29
|
+
const embedder = opts.embedder ?? defaultEmbedder();
|
|
30
|
+
const logger = opts.logger;
|
|
31
|
+
try {
|
|
32
|
+
const tree = buildRaptorTree(leaves, {
|
|
33
|
+
embedder,
|
|
34
|
+
budgetMs: opts.budgetMs,
|
|
35
|
+
clustersPerLevel: opts.clustersPerLevel,
|
|
36
|
+
consistencyThreshold: opts.consistencyThreshold,
|
|
37
|
+
});
|
|
38
|
+
saveRaptorTree(opts.sessionId, tree, opts.stateDir);
|
|
39
|
+
logger?.info("raptor_build", {
|
|
40
|
+
sessionId: opts.sessionId,
|
|
41
|
+
nodes: tree.nodes.size,
|
|
42
|
+
levels: tree.levels,
|
|
43
|
+
rootId: tree.rootId,
|
|
44
|
+
timedOut: tree.timedOut,
|
|
45
|
+
shadow: isShadowMode(),
|
|
46
|
+
});
|
|
47
|
+
if (isShadowMode()) {
|
|
48
|
+
// Build + log only. Do NOT replace retrieval.
|
|
49
|
+
logger?.info("raptor_shadow", { sessionId: opts.sessionId, served: false });
|
|
50
|
+
}
|
|
51
|
+
return tree;
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
logger?.error("raptor_build_failed", {
|
|
55
|
+
sessionId: opts.sessionId,
|
|
56
|
+
error: String(e instanceof Error ? e.message : e),
|
|
57
|
+
});
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Staged retrieval over a persisted/session tree. Only meaningful when RAPTOR
|
|
63
|
+
* is promoted (Sprint 14); provided here so eval can measure it in shadow.
|
|
64
|
+
*
|
|
65
|
+
* Returns the leaf ids the staged expansion would serve for `query`.
|
|
66
|
+
*/
|
|
67
|
+
export function recallRaptor(query, sessionId, opts) {
|
|
68
|
+
const embedder = opts.embedder ?? defaultEmbedder();
|
|
69
|
+
const nodes = listRaptorNodes(sessionId, opts.stateDir);
|
|
70
|
+
if (nodes.length === 0)
|
|
71
|
+
return [];
|
|
72
|
+
// Rehydrate a minimal in-memory tree (parent links reconstructed from children).
|
|
73
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
74
|
+
const tree = {
|
|
75
|
+
nodes: new Map(nodes.map((n) => [
|
|
76
|
+
n.id,
|
|
77
|
+
{
|
|
78
|
+
id: n.id,
|
|
79
|
+
level: n.level,
|
|
80
|
+
parentId: n.parentId,
|
|
81
|
+
children: n.children,
|
|
82
|
+
summary: n.summary,
|
|
83
|
+
embedding: n.embedding,
|
|
84
|
+
qualityMarker: n.qualityMarker,
|
|
85
|
+
tokenEstimate: n.tokenEstimate,
|
|
86
|
+
},
|
|
87
|
+
])),
|
|
88
|
+
rootId: nodes.reduce((best, n) => (!best || n.level > (best?.level ?? -1) ? n : best), null)?.id ?? null,
|
|
89
|
+
levels: Math.max(1, ...nodes.map((n) => n.level + 1)),
|
|
90
|
+
timedOut: false,
|
|
91
|
+
};
|
|
92
|
+
void byId;
|
|
93
|
+
return stagedExpansion(query, tree, { embedder, k: opts.k, topM: opts.topM });
|
|
94
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kmeans.ts — k-means++ clustering over embeddings (Sprint 13, RAPTOR Phase 6).
|
|
3
|
+
*
|
|
4
|
+
* Pure TS, zero deps, deterministic (seeded). Used to group leaf chunks into
|
|
5
|
+
* nodes for the RAPTOR summary tree. QA #11: GMM is the spec's long-term target
|
|
6
|
+
* for cosine space; k-means++ is the shippable local default, with a
|
|
7
|
+
* near-zero-variance merge guard so degenerate inputs can't spin forever.
|
|
8
|
+
*
|
|
9
|
+
* PREVENT-PI-004: math only, no network, no model.
|
|
10
|
+
*/
|
|
11
|
+
import { cosineSimilarity } from "../../embedder.js";
|
|
12
|
+
/** Seeded PRNG (mulberry32) so clustering is deterministic across runs. */
|
|
13
|
+
function rng(seed) {
|
|
14
|
+
let a = seed >>> 0;
|
|
15
|
+
return () => {
|
|
16
|
+
a |= 0;
|
|
17
|
+
a = (a + 0x6d2b79f5) | 0;
|
|
18
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
19
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
20
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/** L2 distance squared between two equal-length vectors. */
|
|
24
|
+
function dist2(a, b) {
|
|
25
|
+
let s = 0;
|
|
26
|
+
for (let i = 0; i < a.length; i++) {
|
|
27
|
+
const d = a[i] - b[i];
|
|
28
|
+
s += d * d;
|
|
29
|
+
}
|
|
30
|
+
return s;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* k-means++ clustering. Deterministic given `seed`.
|
|
34
|
+
*
|
|
35
|
+
* Near-zero-variance guard (QA #11): if the max pairwise distance among input
|
|
36
|
+
* points is below `varianceFloor`, all points are effectively identical — return
|
|
37
|
+
* a single cluster (the mean) instead of iterating. Prevents the centroid-init
|
|
38
|
+
* from dividing by ~0 and keeps degenerate sessions from spinning.
|
|
39
|
+
*/
|
|
40
|
+
export function kmeanspp(points, k, opts = {}) {
|
|
41
|
+
const n = points.length;
|
|
42
|
+
const seed = opts.seed ?? 0x9e3779b9;
|
|
43
|
+
const maxIter = opts.maxIter ?? 25;
|
|
44
|
+
const varianceFloor = opts.varianceFloor ?? 1e-12;
|
|
45
|
+
if (n === 0)
|
|
46
|
+
return { assignments: [], centroids: [], k: 0 };
|
|
47
|
+
if (n === 1)
|
|
48
|
+
return { assignments: [0], centroids: [points[0].slice()], k: 1 };
|
|
49
|
+
// Near-zero-variance merge guard.
|
|
50
|
+
let maxPair = 0;
|
|
51
|
+
for (let i = 0; i < n; i++) {
|
|
52
|
+
for (let j = i + 1; j < n; j++) {
|
|
53
|
+
const d = dist2(points[i], points[j]);
|
|
54
|
+
if (d > maxPair)
|
|
55
|
+
maxPair = d;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (maxPair < varianceFloor) {
|
|
59
|
+
const mean = new Array(points[0].length).fill(0);
|
|
60
|
+
for (const p of points)
|
|
61
|
+
for (let i = 0; i < mean.length; i++)
|
|
62
|
+
mean[i] += p[i] / n;
|
|
63
|
+
return { assignments: new Array(n).fill(0), centroids: [mean], k: 1 };
|
|
64
|
+
}
|
|
65
|
+
const kk = Math.min(k, n);
|
|
66
|
+
const rand = rng(seed);
|
|
67
|
+
// k-means++ seeding: first centroid random, rest weighted by squared distance.
|
|
68
|
+
const centroids = [points[Math.floor(rand() * n)].slice()];
|
|
69
|
+
const d2 = new Array(n).fill(Infinity);
|
|
70
|
+
while (centroids.length < kk) {
|
|
71
|
+
let sum = 0;
|
|
72
|
+
for (let i = 0; i < n; i++) {
|
|
73
|
+
const d = dist2(points[i], centroids[centroids.length - 1]);
|
|
74
|
+
if (d < d2[i])
|
|
75
|
+
d2[i] = d;
|
|
76
|
+
sum += d2[i];
|
|
77
|
+
}
|
|
78
|
+
if (sum === 0) {
|
|
79
|
+
// All remaining points coincide with an existing centroid.
|
|
80
|
+
for (const p of points)
|
|
81
|
+
if (!centroids.some((c) => dist2(c, p) < 1e-12))
|
|
82
|
+
centroids.push(p.slice());
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
let target = rand() * sum;
|
|
86
|
+
let chosen = 0;
|
|
87
|
+
for (let i = 0; i < n; i++) {
|
|
88
|
+
target -= d2[i];
|
|
89
|
+
if (target <= 0) {
|
|
90
|
+
chosen = i;
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
centroids.push(points[chosen].slice());
|
|
95
|
+
}
|
|
96
|
+
const assignments = new Array(n).fill(0);
|
|
97
|
+
for (let iter = 0; iter < maxIter; iter++) {
|
|
98
|
+
let changed = false;
|
|
99
|
+
// Assign each point to its nearest centroid.
|
|
100
|
+
for (let i = 0; i < n; i++) {
|
|
101
|
+
let best = 0;
|
|
102
|
+
let bestD = Infinity;
|
|
103
|
+
for (let c = 0; c < centroids.length; c++) {
|
|
104
|
+
const d = dist2(points[i], centroids[c]);
|
|
105
|
+
if (d < bestD) {
|
|
106
|
+
bestD = d;
|
|
107
|
+
best = c;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (assignments[i] !== best) {
|
|
111
|
+
assignments[i] = best;
|
|
112
|
+
changed = true;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// Recompute centroids as the mean of assigned points.
|
|
116
|
+
const sums = centroids.map(() => new Array(points[0].length).fill(0));
|
|
117
|
+
const counts = new Array(centroids.length).fill(0);
|
|
118
|
+
for (let i = 0; i < n; i++) {
|
|
119
|
+
const c = assignments[i];
|
|
120
|
+
counts[c]++;
|
|
121
|
+
for (let d = 0; d < points[i].length; d++)
|
|
122
|
+
sums[c][d] += points[i][d];
|
|
123
|
+
}
|
|
124
|
+
for (let c = 0; c < centroids.length; c++) {
|
|
125
|
+
if (counts[c] === 0)
|
|
126
|
+
continue; // keep prior centroid if a cluster emptied
|
|
127
|
+
centroids[c] = sums[c].map((s) => s / counts[c]);
|
|
128
|
+
}
|
|
129
|
+
if (!changed && iter > 0)
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
return { assignments, centroids, k: centroids.length };
|
|
133
|
+
}
|
|
134
|
+
/** Mean embedding of a set of vectors (used to reduce a cluster to its centroid). */
|
|
135
|
+
export function meanVector(vectors) {
|
|
136
|
+
if (vectors.length === 0)
|
|
137
|
+
return [];
|
|
138
|
+
const dim = vectors[0].length;
|
|
139
|
+
const sum = new Array(dim).fill(0);
|
|
140
|
+
for (const v of vectors)
|
|
141
|
+
for (let i = 0; i < dim; i++)
|
|
142
|
+
sum[i] += v[i];
|
|
143
|
+
return sum.map((s) => s / vectors.length);
|
|
144
|
+
}
|
|
145
|
+
/** Centroid of the union of two clusters (cosine-space "midpoint" via mean). */
|
|
146
|
+
export function mergeCentroids(a, b) {
|
|
147
|
+
return meanVector([a, b]);
|
|
148
|
+
}
|
|
149
|
+
/** Cosine distance (1 - cosine) — used by tree.ts for budget/quality checks. */
|
|
150
|
+
export function cosineDistance(a, b) {
|
|
151
|
+
return 1 - cosineSimilarity(a, b);
|
|
152
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* raptor.test.ts — hermetic unit tests for the Sprint 13 RAPTOR module.
|
|
3
|
+
*
|
|
4
|
+
* No network: the default summarizer is deterministic extractive, and Ollama is
|
|
5
|
+
* only reached when MEGACOMPACT_RAPTOR_MODEL is set (never here). Retrieval from
|
|
6
|
+
* the live store is never touched (shadow mode), so recallAndInline output is
|
|
7
|
+
* unchanged by construction.
|
|
8
|
+
*/
|
|
9
|
+
import { test } from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { TrigramEmbedder } from "../../embedder.js";
|
|
15
|
+
import { kmeanspp } from "./kmeans.js";
|
|
16
|
+
import { applyHallucinationGuardrails, sourceTokenSet, extractEntities, makeUngroundedSummary, } from "./guardrails.js";
|
|
17
|
+
import { buildRaptorTree } from "./tree.js";
|
|
18
|
+
import { stagedExpansion } from "./retrieval.js";
|
|
19
|
+
import { runRaptor } from "./index.js";
|
|
20
|
+
import { Logger } from "../../log.js";
|
|
21
|
+
import { listRaptorNodes, clearRaptorNodes, closeStore, } from "../../store/sqlite.js";
|
|
22
|
+
const baseTmp = mkdtempSync(join(tmpdir(), "mc-raptor-"));
|
|
23
|
+
function msg(text) {
|
|
24
|
+
return { role: "user", text };
|
|
25
|
+
}
|
|
26
|
+
/** Build N distinct leaves with deterministic content. */
|
|
27
|
+
function makeLeaves(n, embedder = new TrigramEmbedder()) {
|
|
28
|
+
const leaves = [];
|
|
29
|
+
for (let i = 0; i < n; i++) {
|
|
30
|
+
const text = `topic ${i % 7}: the module ${i} validated the session token and refreshed the cache for region ${i}`;
|
|
31
|
+
leaves.push({
|
|
32
|
+
id: `leaf_${i}`,
|
|
33
|
+
messages: [msg(text)],
|
|
34
|
+
sourceText: text,
|
|
35
|
+
embedding: embedder.embed(text),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return leaves;
|
|
39
|
+
}
|
|
40
|
+
// --- kmeans: near-zero-variance merge guard ---------------------------------
|
|
41
|
+
test("kmeanspp merges identical points into a single cluster (QA #11)", () => {
|
|
42
|
+
const p = [1, 0, 0];
|
|
43
|
+
const points = [p, p, p, p, p];
|
|
44
|
+
const r = kmeanspp(points, 3, { seed: 1 });
|
|
45
|
+
assert.equal(r.k, 1);
|
|
46
|
+
assert.deepEqual(r.assignments, [0, 0, 0, 0, 0]);
|
|
47
|
+
});
|
|
48
|
+
test("kmeanspp separates two well-separated clusters", () => {
|
|
49
|
+
const a = [1, 0, 0];
|
|
50
|
+
const b = [0, 1, 0];
|
|
51
|
+
const points = [a, a, a, b, b, b];
|
|
52
|
+
const r = kmeanspp(points, 2, { seed: 7 });
|
|
53
|
+
assert.equal(r.k, 2);
|
|
54
|
+
// All the a's share one assignment, all the b's another.
|
|
55
|
+
const firstGroup = r.assignments[0];
|
|
56
|
+
const secondGroup = r.assignments[3];
|
|
57
|
+
assert.notEqual(firstGroup, secondGroup);
|
|
58
|
+
for (let i = 0; i < 3; i++)
|
|
59
|
+
assert.equal(r.assignments[i], firstGroup);
|
|
60
|
+
for (let i = 3; i < 6; i++)
|
|
61
|
+
assert.equal(r.assignments[i], secondGroup);
|
|
62
|
+
});
|
|
63
|
+
// --- guardrails: catch hallucination (QA #16) ------------------------------
|
|
64
|
+
test("guardrails flag an un-grounded entity as extractive_fallback", () => {
|
|
65
|
+
const embedder = new TrigramEmbedder();
|
|
66
|
+
const realSource = "the auth module validates the session token";
|
|
67
|
+
const summary = makeUngroundedSummary(realSource, "ZkPhant0mCorp");
|
|
68
|
+
const sources = [realSource];
|
|
69
|
+
const r = applyHallucinationGuardrails({
|
|
70
|
+
summary,
|
|
71
|
+
sources,
|
|
72
|
+
centroid: embedder.embed(realSource),
|
|
73
|
+
embedder,
|
|
74
|
+
sourceTokens: sourceTokenSet(sources),
|
|
75
|
+
});
|
|
76
|
+
assert.equal(r.marker, "extractive_fallback");
|
|
77
|
+
assert.equal(r.grounded, false);
|
|
78
|
+
});
|
|
79
|
+
test("guardrails pass a faithful, grounded summary", () => {
|
|
80
|
+
const embedder = new TrigramEmbedder();
|
|
81
|
+
const src = "the auth module validates the session token and refreshes the cache";
|
|
82
|
+
const summary = "the auth module validates the session token";
|
|
83
|
+
const sources = [src];
|
|
84
|
+
const r = applyHallucinationGuardrails({
|
|
85
|
+
summary,
|
|
86
|
+
sources,
|
|
87
|
+
centroid: embedder.embed(src),
|
|
88
|
+
embedder,
|
|
89
|
+
sourceTokens: sourceTokenSet(sources),
|
|
90
|
+
});
|
|
91
|
+
assert.equal(r.grounded, true);
|
|
92
|
+
assert.ok(r.marker === "high" || r.marker === "low");
|
|
93
|
+
assert.notEqual(r.marker, "extractive_fallback");
|
|
94
|
+
});
|
|
95
|
+
test("extractEntities lowercases candidate tokens", () => {
|
|
96
|
+
const e = extractEntities("The AuthModule validates the token_abc 42 times");
|
|
97
|
+
assert.ok(e.includes("authmodule"));
|
|
98
|
+
assert.ok(e.includes("token_abc"));
|
|
99
|
+
});
|
|
100
|
+
// --- tree: <10 leaves → single node -----------------------------------------
|
|
101
|
+
test("tree with <10 leaves yields a single root node", () => {
|
|
102
|
+
const leaves = makeLeaves(5);
|
|
103
|
+
const tree = buildRaptorTree(leaves, { embedder: new TrigramEmbedder() });
|
|
104
|
+
assert.equal(tree.nodes.size, 1);
|
|
105
|
+
assert.equal(tree.rootId, "r0_0");
|
|
106
|
+
assert.equal(tree.levels, 1);
|
|
107
|
+
assert.equal(tree.timedOut, false);
|
|
108
|
+
});
|
|
109
|
+
// --- tree: 1K fixture builds within budget ----------------------------------
|
|
110
|
+
test("tree builds within 5s on a 1000-leaf fixture", () => {
|
|
111
|
+
const leaves = makeLeaves(1000);
|
|
112
|
+
const t0 = Date.now();
|
|
113
|
+
const tree = buildRaptorTree(leaves, {
|
|
114
|
+
embedder: new TrigramEmbedder(),
|
|
115
|
+
budgetMs: 5000,
|
|
116
|
+
clustersPerLevel: 8,
|
|
117
|
+
});
|
|
118
|
+
const elapsed = Date.now() - t0;
|
|
119
|
+
assert.ok(elapsed < 5000, `build took ${elapsed}ms (over 5s budget)`);
|
|
120
|
+
assert.ok(tree.nodes.size > 1, "expected a multi-level tree");
|
|
121
|
+
assert.ok(tree.rootId !== null);
|
|
122
|
+
});
|
|
123
|
+
// --- tree: budget timeout → extractive fallback root ------------------------
|
|
124
|
+
test("tree respects the budget: a tiny budget forces an extractive fallback root", () => {
|
|
125
|
+
const leaves = makeLeaves(200);
|
|
126
|
+
const tree = buildRaptorTree(leaves, {
|
|
127
|
+
embedder: new TrigramEmbedder(),
|
|
128
|
+
budgetMs: 0, // forces immediate timeout on the first level
|
|
129
|
+
clustersPerLevel: 4,
|
|
130
|
+
});
|
|
131
|
+
assert.equal(tree.timedOut, true);
|
|
132
|
+
assert.equal(tree.rootId, "r99_0");
|
|
133
|
+
assert.ok(tree.nodes.has("r99_0"));
|
|
134
|
+
});
|
|
135
|
+
// --- retrieval: staged expansion returns leaf ids ---------------------------
|
|
136
|
+
test("stagedExpansion returns diversified leaf ids for a query", () => {
|
|
137
|
+
const leaves = makeLeaves(20);
|
|
138
|
+
const tree = buildRaptorTree(leaves, { embedder: new TrigramEmbedder(), clustersPerLevel: 4 });
|
|
139
|
+
const hits = stagedExpansion("the auth module validates the session token", tree, {
|
|
140
|
+
embedder: new TrigramEmbedder(),
|
|
141
|
+
k: 3,
|
|
142
|
+
topM: 3,
|
|
143
|
+
});
|
|
144
|
+
assert.ok(hits.length > 0);
|
|
145
|
+
assert.ok(hits.length <= 3);
|
|
146
|
+
// All returned ids are raw leaf ids (not internal node ids).
|
|
147
|
+
for (const id of hits)
|
|
148
|
+
assert.ok(id.startsWith("leaf_"));
|
|
149
|
+
});
|
|
150
|
+
// --- shadow mode: builds + persists, never alters retrieval -----------------
|
|
151
|
+
test("runRaptor builds + persists a shadow tree without throwing", () => {
|
|
152
|
+
const dir = join(baseTmp, `shadow-${Math.floor(performance.now())}`);
|
|
153
|
+
const leaves = makeLeaves(40);
|
|
154
|
+
const logger = new Logger({
|
|
155
|
+
enabled: true,
|
|
156
|
+
now: () => 0,
|
|
157
|
+
path: join(dir, "events.log"),
|
|
158
|
+
});
|
|
159
|
+
const tree = runRaptor(leaves, {
|
|
160
|
+
stateDir: dir,
|
|
161
|
+
sessionId: "sess_raptor",
|
|
162
|
+
embedder: new TrigramEmbedder(),
|
|
163
|
+
logger,
|
|
164
|
+
});
|
|
165
|
+
assert.ok(tree !== null);
|
|
166
|
+
// Tree was persisted to raptor_nodes.
|
|
167
|
+
const stored = listRaptorNodes("sess_raptor", dir);
|
|
168
|
+
assert.ok(stored.length > 0, "shadow tree should be persisted");
|
|
169
|
+
// Quality markers are valid values.
|
|
170
|
+
for (const n of stored) {
|
|
171
|
+
assert.ok(["high", "low", "extractive_fallback"].includes(n.qualityMarker) || n.qualityMarker === "low");
|
|
172
|
+
}
|
|
173
|
+
clearRaptorNodes("sess_raptor", dir);
|
|
174
|
+
closeStore(dir);
|
|
175
|
+
});
|
|
176
|
+
test("shadow mode does NOT change recallAndInline output (separation)", () => {
|
|
177
|
+
// The orchestrator persists to raptor_nodes only. The live store's
|
|
178
|
+
// recallAndInline path (vectorStore.search) reads context_chunks, never
|
|
179
|
+
// raptor_nodes, so building a RAPTOR tree is observably independent.
|
|
180
|
+
const dir = join(baseTmp, `sep-${Math.floor(performance.now())}`);
|
|
181
|
+
const leaves = makeLeaves(15);
|
|
182
|
+
const before = listRaptorNodes("sess_sep", dir);
|
|
183
|
+
runRaptor(leaves, {
|
|
184
|
+
stateDir: dir,
|
|
185
|
+
sessionId: "sess_sep",
|
|
186
|
+
embedder: new TrigramEmbedder(),
|
|
187
|
+
});
|
|
188
|
+
const after = listRaptorNodes("sess_sep", dir);
|
|
189
|
+
// Building RAPTOR only ever writes raptor_nodes; context_chunks is untouched.
|
|
190
|
+
assert.deepEqual(before, []);
|
|
191
|
+
assert.ok(after.length > 0);
|
|
192
|
+
clearRaptorNodes("sess_sep", dir);
|
|
193
|
+
closeStore(dir);
|
|
194
|
+
});
|
|
195
|
+
// --- eval: redundancy reduction ≥ 15% (nodes << leaves) ---------------------
|
|
196
|
+
test("eval: RAPTOR reduces node count substantially vs flat (≥15%)", () => {
|
|
197
|
+
const leaves = makeLeaves(100);
|
|
198
|
+
const tree = buildRaptorTree(leaves, { embedder: new TrigramEmbedder(), clustersPerLevel: 8 });
|
|
199
|
+
const reduction = 1 - tree.nodes.size / leaves.length;
|
|
200
|
+
assert.ok(reduction >= 0.15, `redundancy reduction ${reduction.toFixed(2)} < 0.15`);
|
|
201
|
+
});
|
|
202
|
+
// --- cleanup ----------------------------------------------------------------
|
|
203
|
+
test("RAPTOR cleanup", () => {
|
|
204
|
+
rmSync(baseTmp, { recursive: true, force: true });
|
|
205
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
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
|
+
import { cosineSimilarity } from "../../embedder.js";
|
|
15
|
+
import { mmrRerank } from "../mmr.js";
|
|
16
|
+
/** Any child id not present in the node map is a raw leaf id. */
|
|
17
|
+
function isLeafId(id, tree) {
|
|
18
|
+
return !tree.nodes.has(id);
|
|
19
|
+
}
|
|
20
|
+
/** All leaf (raw) ids reachable beneath a node via BFS. */
|
|
21
|
+
function leafDescendants(node, tree) {
|
|
22
|
+
const out = [];
|
|
23
|
+
const queue = [node];
|
|
24
|
+
while (queue.length) {
|
|
25
|
+
const cur = queue.shift();
|
|
26
|
+
for (const childId of cur.children) {
|
|
27
|
+
if (isLeafId(childId, tree))
|
|
28
|
+
out.push(childId);
|
|
29
|
+
else {
|
|
30
|
+
const child = tree.nodes.get(childId);
|
|
31
|
+
if (child)
|
|
32
|
+
queue.push(child);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Staged expansion retrieval over a RAPTOR tree.
|
|
40
|
+
*
|
|
41
|
+
* Returns up to `k` leaf ids, diversified by MMR. Deterministic given the
|
|
42
|
+
* tree + query. Throws nothing — returns [] on empty tree.
|
|
43
|
+
*/
|
|
44
|
+
export function stagedExpansion(query, tree, opts) {
|
|
45
|
+
if (!tree.rootId)
|
|
46
|
+
return [];
|
|
47
|
+
const embedder = opts.embedder;
|
|
48
|
+
const topM = opts.topM ?? 3;
|
|
49
|
+
const k = opts.k ?? 5;
|
|
50
|
+
const lambda = opts.mmrLambda ?? 0.5;
|
|
51
|
+
const qv = embedder.embed(query);
|
|
52
|
+
// 1. ANN: score every node at every level by cosine to the query.
|
|
53
|
+
const scored = [...tree.nodes.values()].map((n) => ({
|
|
54
|
+
node: n,
|
|
55
|
+
score: cosineSimilarity(qv, n.embedding),
|
|
56
|
+
}));
|
|
57
|
+
// 2. expand: top-M nodes overall (BFS anchors).
|
|
58
|
+
const anchors = scored
|
|
59
|
+
.slice()
|
|
60
|
+
.sort((a, b) => b.score - a.score)
|
|
61
|
+
.slice(0, topM)
|
|
62
|
+
.map((s) => s.node);
|
|
63
|
+
// 3. BFS to leaves from those anchors.
|
|
64
|
+
const leaves = new Map();
|
|
65
|
+
for (const a of anchors) {
|
|
66
|
+
for (const lid of leafDescendants(a, tree)) {
|
|
67
|
+
// Represent each leaf by its nearest internal parent so we can score it.
|
|
68
|
+
// (The leaf's own centroid is stored on the level-0 node that wraps it.)
|
|
69
|
+
const parent = [...tree.nodes.values()].find((n) => n.children.includes(lid));
|
|
70
|
+
if (parent)
|
|
71
|
+
leaves.set(lid, parent);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// 4. MMR diversify the expanded leaf set by their (parent) embeddings.
|
|
75
|
+
const items = [...leaves.entries()].map(([lid, n]) => ({
|
|
76
|
+
item: lid,
|
|
77
|
+
vector: n.embedding,
|
|
78
|
+
relevance: cosineSimilarity(qv, n.embedding),
|
|
79
|
+
}));
|
|
80
|
+
return mmrRerank(items, k, lambda);
|
|
81
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
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
|
+
import { extractiveSummarize } from "../../extractive.js";
|
|
13
|
+
import { estimateBlockTokens } from "../../tokens.js";
|
|
14
|
+
import { spawnSync } from "node:child_process"; // guardrails-allow PREVENT-PI-004: localhost-only user-spawned Ollama server (BYO local model, never remote)
|
|
15
|
+
/** The local Ollama endpoint (loopback). Read lazily so tests can avoid it. */
|
|
16
|
+
function ollamaEndpoint() {
|
|
17
|
+
const model = process.env.MEGACOMPACT_RAPTOR_MODEL;
|
|
18
|
+
if (!model)
|
|
19
|
+
return null;
|
|
20
|
+
const base = process.env.MEGACOMPACT_RAPTOR_URL ?? "http://127.0.0.1:11434";
|
|
21
|
+
// Guard: only loopback is permitted (remote Ollama would violate PREVENT-PI-004).
|
|
22
|
+
if (!/^https?:\/\/(localhost|127\.0\.0\.1)([:/]|$)/.test(base)) {
|
|
23
|
+
throw new Error(`MEGACOMPACT_RAPTOR_URL must be localhost/127.0.0.1 (got ${base}). ` +
|
|
24
|
+
`Remote Ollama is not allowed (PREVENT-PI-004).`);
|
|
25
|
+
}
|
|
26
|
+
return { url: `${base}/api/generate`, model };
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Extractive summarization of a cluster's source messages. Deterministic and
|
|
30
|
+
* fully local — the on-by-default path.
|
|
31
|
+
*/
|
|
32
|
+
export function extractiveClusterSummary(messages) {
|
|
33
|
+
const s = extractiveSummarize(messages);
|
|
34
|
+
return { summary: s.topicSummary, tokenEstimate: s.tokenEstimate };
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Build a summary for one cluster of source messages.
|
|
38
|
+
*
|
|
39
|
+
* Uses local Ollama when MEGACOMPACT_RAPTOR_MODEL is set (localhost-only);
|
|
40
|
+
* otherwise falls back to deterministic extractive. The `fetch` is a localhost
|
|
41
|
+
* call inside the PREVENT-PI-004 exception — annotated accordingly.
|
|
42
|
+
*/
|
|
43
|
+
export function summarizeCluster(messages) {
|
|
44
|
+
const ollama = ollamaEndpoint();
|
|
45
|
+
if (!ollama)
|
|
46
|
+
return extractiveClusterSummary(messages);
|
|
47
|
+
return ollamaSummarize(messages, ollama);
|
|
48
|
+
}
|
|
49
|
+
function ollamaSummarize(messages, ollama) {
|
|
50
|
+
// The fetch below is localhost-only (loopback Ollama) — the PREVENT-PI-004
|
|
51
|
+
// sanctioned local-model exceptions (same class as /dashboard, HttpEmbedder).
|
|
52
|
+
const prompt = messages.map((m) => `${m.role}: ${m.text}`).join("\n");
|
|
53
|
+
// Synchronous bridge: spawnSync an inline worker so the call blocks without
|
|
54
|
+
// deadlocking fetch (mirrors HttpEmbedder — Atomics.wait on main thread would
|
|
55
|
+
// hang). A blocked main thread cannot pump the socket.
|
|
56
|
+
const WORKER = String.raw `
|
|
57
|
+
const url = process.env.R_URL, model = process.env.R_MODEL, prompt = process.env.R_PROMPT;
|
|
58
|
+
try {
|
|
59
|
+
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)
|
|
60
|
+
const j = await r.json();
|
|
61
|
+
process.stdout.write(JSON.stringify({ ok: r.ok, text: j.response || "" }));
|
|
62
|
+
} catch (e) {
|
|
63
|
+
process.stdout.write(JSON.stringify({ ok: false, error: String(e && e.message ? e.message : e) }));
|
|
64
|
+
}
|
|
65
|
+
`;
|
|
66
|
+
const res = spawnSync(process.execPath, ["-e", WORKER], {
|
|
67
|
+
encoding: "utf8",
|
|
68
|
+
env: { ...process.env, R_URL: ollama.url, R_MODEL: ollama.model, R_PROMPT: prompt },
|
|
69
|
+
});
|
|
70
|
+
let parsed = { ok: false, error: "no response" };
|
|
71
|
+
if (typeof res.stdout === "string" && res.stdout.length > 0) {
|
|
72
|
+
try {
|
|
73
|
+
parsed = JSON.parse(res.stdout);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
parsed = { ok: false, error: "bad json" };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (!parsed.ok || !parsed.text) {
|
|
80
|
+
// Ollama unavailable → deterministic extractive fallback (never fail the build).
|
|
81
|
+
return extractiveClusterSummary(messages);
|
|
82
|
+
}
|
|
83
|
+
const summary = parsed.text.trim();
|
|
84
|
+
return { summary, tokenEstimate: estimateBlockTokens(summary) };
|
|
85
|
+
}
|