pi-mega-compact 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +24 -0
- package/README.md +375 -0
- package/extensions/DASHBOARD.md +160 -0
- package/extensions/dashboard-server.test.ts +124 -0
- package/extensions/dashboard-server.ts +459 -0
- package/extensions/error-patterns.ts +175 -0
- package/extensions/mega-compact.test.ts +351 -0
- package/extensions/mega-compact.ts +846 -0
- package/extensions/openclaw-mega-compact.ts +370 -0
- package/package.json +61 -0
- package/src/adapt.ts +120 -0
- package/src/boundary.test.ts +61 -0
- package/src/boundary.ts +94 -0
- package/src/canary.ts +126 -0
- package/src/compact.test.ts +99 -0
- package/src/compact.ts +262 -0
- package/src/config/dedup.ts +120 -0
- package/src/config.ts +15 -0
- package/src/dedup/dedup.test.ts +46 -0
- package/src/dedup/digest.ts +40 -0
- package/src/dedup/l1-lsh.ts +67 -0
- package/src/dedup/l1-minhash.ts +90 -0
- package/src/dedup/l1-verify.ts +55 -0
- package/src/dedup/l1.test.ts +57 -0
- package/src/dedup/mmr.ts +54 -0
- package/src/dedup/normalize.ts +41 -0
- package/src/dedup/raptor/guardrails.ts +112 -0
- package/src/dedup/raptor/index.ts +118 -0
- package/src/dedup/raptor/kmeans.ts +156 -0
- package/src/dedup/raptor/raptor.test.ts +238 -0
- package/src/dedup/raptor/retrieval.ts +102 -0
- package/src/dedup/raptor/summarizer.ts +91 -0
- package/src/dedup/raptor/tree.ts +254 -0
- package/src/dedup/sprint12.test.ts +242 -0
- package/src/dedup/topk.ts +61 -0
- package/src/dedup-engine.test.ts +609 -0
- package/src/e2e.test.ts +843 -0
- package/src/embedder.ts +111 -0
- package/src/engine.test.ts +123 -0
- package/src/engine.ts +192 -0
- package/src/extractive.test.ts +156 -0
- package/src/extractive.ts +265 -0
- package/src/httpEmbedder.ts +154 -0
- package/src/log.test.ts +47 -0
- package/src/log.ts +60 -0
- package/src/monitoring.ts +171 -0
- package/src/ratio.bench.test.ts +1316 -0
- package/src/recall.integration.test.ts +96 -0
- package/src/recall.test.ts +59 -0
- package/src/recall.ts +100 -0
- package/src/sprint14.test.ts +245 -0
- package/src/store/backfill.ts +263 -0
- package/src/store/bloom.ts +122 -0
- package/src/store/compression.test.ts +83 -0
- package/src/store/compression.ts +203 -0
- package/src/store/integrity.ts +65 -0
- package/src/store/migrate.test.ts +158 -0
- package/src/store/migrate.ts +108 -0
- package/src/store/sprint10.test.ts +182 -0
- package/src/store/sqlite.ts +519 -0
- package/src/store.test.ts +169 -0
- package/src/store.ts +192 -0
- package/src/supersede.test.ts +42 -0
- package/src/supersede.ts +67 -0
- package/src/tokens.ts +35 -0
- package/src/types.test.ts +10 -0
- package/src/types.ts +49 -0
- package/src/vectorStore.test.ts +480 -0
- package/src/vectorStore.ts +544 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* guardrails.ts — hallucination defense for RAPTOR summary nodes (Sprint 13, QA #16).
|
|
3
|
+
*
|
|
4
|
+
* Four layers gate a candidate summary before it may be marked high-quality:
|
|
5
|
+
* 1. Claim grounding — every claim in the summary maps to source text
|
|
6
|
+
* (no entity/claim appears that isn't supported by a source chunk).
|
|
7
|
+
* 2. Entity coverage — fraction of summary entities that are present in source.
|
|
8
|
+
* 3. Consistency — cosine(reEmbed(summary), cluster centroid) ≥ threshold.
|
|
9
|
+
* This is the HARD gate: a low score means the summary drifted from the
|
|
10
|
+
* source cluster, so we fall back to extractive (never serve a low-quality
|
|
11
|
+
* LLM summary).
|
|
12
|
+
* 4. Quality markers — 'high' | 'low' | 'extractive_fallback' assigned from the
|
|
13
|
+
* above.
|
|
14
|
+
*
|
|
15
|
+
* Pure functions, no network, no model. The consistency check uses the caller's
|
|
16
|
+
* embedder (the same local Embedder used everywhere else).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { Embedder, Vector } from "../../embedder.js";
|
|
20
|
+
import { cosineSimilarity } from "../../embedder.js";
|
|
21
|
+
|
|
22
|
+
export type QualityMarker = "high" | "low" | "extractive_fallback";
|
|
23
|
+
|
|
24
|
+
export interface GuardrailInput {
|
|
25
|
+
summary: string;
|
|
26
|
+
/** Source chunk texts the summary is supposed to cover. */
|
|
27
|
+
sources: string[];
|
|
28
|
+
/** Cluster centroid (embedding) the summary must stay consistent with. */
|
|
29
|
+
centroid: Vector;
|
|
30
|
+
/** The local embedder (reused from the rest of the pipeline). */
|
|
31
|
+
embedder: Embedder;
|
|
32
|
+
/** Deterministically-precomputed source tokens (uppercased words) for grounding. */
|
|
33
|
+
sourceTokens: Set<string>;
|
|
34
|
+
/** Consistency threshold; below this → extractive fallback (QA #16). */
|
|
35
|
+
consistencyThreshold?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface GuardrailResult {
|
|
39
|
+
marker: QualityMarker;
|
|
40
|
+
entityCoverage: number; // 0..1
|
|
41
|
+
consistency: number; // cosine(summEmbed, centroid)
|
|
42
|
+
grounded: boolean;
|
|
43
|
+
reason: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const ENTITY_RE = /\b([A-Z][a-zA-Z0-9_]{2,}|[a-z_]+_[a-z_]+|\d{2,})\b/g;
|
|
47
|
+
|
|
48
|
+
/** Extract candidate "entities"/tokens from a summary for grounding checks. */
|
|
49
|
+
export function extractEntities(text: string): string[] {
|
|
50
|
+
const out = new Set<string>();
|
|
51
|
+
for (const m of text.matchAll(ENTITY_RE)) out.add(m[1].toLowerCase());
|
|
52
|
+
return [...out];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Lowercase word set from a body of source text (for grounding lookups). */
|
|
56
|
+
export function sourceTokenSet(sources: string[]): Set<string> {
|
|
57
|
+
const set = new Set<string>();
|
|
58
|
+
for (const s of sources) for (const w of s.toLowerCase().split(/\W+/)) if (w) set.add(w);
|
|
59
|
+
return set;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Verify a summary against its sources + centroid.
|
|
64
|
+
*
|
|
65
|
+
* Faithfulness (QA #16): consistency is the hard gate. If the summary embedding
|
|
66
|
+
* is insufficiently similar to the cluster centroid, the summary is NOT faithful
|
|
67
|
+
* to the source — mark it 'extractive_fallback' so callers fall back to the
|
|
68
|
+
* deterministic extractive summary instead of serving a drifted LLM summary.
|
|
69
|
+
*
|
|
70
|
+
* grounding: every summary entity must appear in the source token set. A single
|
|
71
|
+
* un-grounded entity fails grounding (caught hallucination).
|
|
72
|
+
*/
|
|
73
|
+
export function applyHallucinationGuardrails(input: GuardrailInput): GuardrailResult {
|
|
74
|
+
const threshold = input.consistencyThreshold ?? 0.6;
|
|
75
|
+
const sourceTokens = input.sourceTokens;
|
|
76
|
+
|
|
77
|
+
// Layer 1 + 2: entity grounding & coverage.
|
|
78
|
+
const entities = extractEntities(input.summary);
|
|
79
|
+
let groundedCount = 0;
|
|
80
|
+
for (const e of entities) {
|
|
81
|
+
if (sourceTokens.has(e)) groundedCount++;
|
|
82
|
+
}
|
|
83
|
+
const entityCoverage = entities.length === 0 ? 1 : groundedCount / entities.length;
|
|
84
|
+
const grounded = entities.length === 0 || groundedCount === entities.length;
|
|
85
|
+
|
|
86
|
+
// Layer 3: consistency re-embed.
|
|
87
|
+
const summEmbed = input.embedder.embed(input.summary);
|
|
88
|
+
const consistency = cosineSimilarity(summEmbed, input.centroid);
|
|
89
|
+
|
|
90
|
+
// Layer 4: quality marker decision.
|
|
91
|
+
if (!grounded || consistency < threshold) {
|
|
92
|
+
return {
|
|
93
|
+
marker: "extractive_fallback",
|
|
94
|
+
entityCoverage,
|
|
95
|
+
consistency,
|
|
96
|
+
grounded,
|
|
97
|
+
reason: !grounded
|
|
98
|
+
? "ungrounded entity in summary"
|
|
99
|
+
: `consistency ${consistency.toFixed(2)} < ${threshold} (drift from source)`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
const marker: QualityMarker = entityCoverage >= 0.7 ? "high" : "low";
|
|
103
|
+
return { marker, entityCoverage, consistency, grounded, reason: "ok" };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Convenience: build a fixture summary that is deliberately un-grounded (used by
|
|
108
|
+
* tests to prove the guardrail CATCHES a hallucination). Not used in production.
|
|
109
|
+
*/
|
|
110
|
+
export function makeUngroundedSummary(realSource: string, fakeEntity: string): string {
|
|
111
|
+
return `${realSource.slice(0, 60)} The quarterly revenue doubled to ${fakeEntity}.`;
|
|
112
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
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
|
+
|
|
13
|
+
import type { Embedder } from "../../embedder.js";
|
|
14
|
+
import { defaultEmbedder } from "../../embedder.js";
|
|
15
|
+
import { buildRaptorTree, type Leaf, type RaptorTree } from "./tree.js";
|
|
16
|
+
import { stagedExpansion } from "./retrieval.js";
|
|
17
|
+
import { Logger } from "../../log.js";
|
|
18
|
+
import { saveRaptorTree, listRaptorNodes } from "../../store/sqlite.js";
|
|
19
|
+
|
|
20
|
+
/** Shadow mode is on by default; set RAPTOR_SHADOW_MODE=false to serve live. */
|
|
21
|
+
export function isShadowMode(): boolean {
|
|
22
|
+
return process.env.RAPTOR_SHADOW_MODE !== "false";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface RaptorOrchestratorOptions {
|
|
26
|
+
embedder?: Embedder;
|
|
27
|
+
stateDir: string;
|
|
28
|
+
sessionId: string;
|
|
29
|
+
budgetMs?: number;
|
|
30
|
+
clustersPerLevel?: number;
|
|
31
|
+
consistencyThreshold?: number;
|
|
32
|
+
/** Best-effort logger for shadow events. */
|
|
33
|
+
logger?: Logger;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build the RAPTOR tree for a session's leaves. Per shadow-mode rules, the tree
|
|
38
|
+
* is persisted + logged regardless; whether it is served is the caller's choice
|
|
39
|
+
* (Sprint 13: it is built but NOT injected into recallAndInline).
|
|
40
|
+
*
|
|
41
|
+
* Returns the built tree (in-memory) for eval/tests, and persists it to the
|
|
42
|
+
* store. Never throws — on any build error it logs and returns null.
|
|
43
|
+
*/
|
|
44
|
+
export function runRaptor(
|
|
45
|
+
leaves: Leaf[],
|
|
46
|
+
opts: RaptorOrchestratorOptions,
|
|
47
|
+
): RaptorTree | null {
|
|
48
|
+
const embedder = opts.embedder ?? defaultEmbedder();
|
|
49
|
+
const logger = opts.logger;
|
|
50
|
+
try {
|
|
51
|
+
const tree = buildRaptorTree(leaves, {
|
|
52
|
+
embedder,
|
|
53
|
+
budgetMs: opts.budgetMs,
|
|
54
|
+
clustersPerLevel: opts.clustersPerLevel,
|
|
55
|
+
consistencyThreshold: opts.consistencyThreshold,
|
|
56
|
+
});
|
|
57
|
+
saveRaptorTree(opts.sessionId, tree, opts.stateDir);
|
|
58
|
+
logger?.info("raptor_build", {
|
|
59
|
+
sessionId: opts.sessionId,
|
|
60
|
+
nodes: tree.nodes.size,
|
|
61
|
+
levels: tree.levels,
|
|
62
|
+
rootId: tree.rootId,
|
|
63
|
+
timedOut: tree.timedOut,
|
|
64
|
+
shadow: isShadowMode(),
|
|
65
|
+
});
|
|
66
|
+
if (isShadowMode()) {
|
|
67
|
+
// Build + log only. Do NOT replace retrieval.
|
|
68
|
+
logger?.info("raptor_shadow", { sessionId: opts.sessionId, served: false });
|
|
69
|
+
}
|
|
70
|
+
return tree;
|
|
71
|
+
} catch (e) {
|
|
72
|
+
logger?.error("raptor_build_failed", {
|
|
73
|
+
sessionId: opts.sessionId,
|
|
74
|
+
error: String(e instanceof Error ? e.message : e),
|
|
75
|
+
});
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Staged retrieval over a persisted/session tree. Only meaningful when RAPTOR
|
|
82
|
+
* is promoted (Sprint 14); provided here so eval can measure it in shadow.
|
|
83
|
+
*
|
|
84
|
+
* Returns the leaf ids the staged expansion would serve for `query`.
|
|
85
|
+
*/
|
|
86
|
+
export function recallRaptor(
|
|
87
|
+
query: string,
|
|
88
|
+
sessionId: string,
|
|
89
|
+
opts: { embedder?: Embedder; stateDir: string; k?: number; topM?: number },
|
|
90
|
+
): string[] {
|
|
91
|
+
const embedder = opts.embedder ?? defaultEmbedder();
|
|
92
|
+
const nodes = listRaptorNodes(sessionId, opts.stateDir);
|
|
93
|
+
if (nodes.length === 0) return [];
|
|
94
|
+
// Rehydrate a minimal in-memory tree (parent links reconstructed from children).
|
|
95
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
96
|
+
const tree: RaptorTree = {
|
|
97
|
+
nodes: new Map(
|
|
98
|
+
nodes.map((n) => [
|
|
99
|
+
n.id,
|
|
100
|
+
{
|
|
101
|
+
id: n.id,
|
|
102
|
+
level: n.level,
|
|
103
|
+
parentId: n.parentId,
|
|
104
|
+
children: n.children,
|
|
105
|
+
summary: n.summary,
|
|
106
|
+
embedding: n.embedding,
|
|
107
|
+
qualityMarker: n.qualityMarker as any,
|
|
108
|
+
tokenEstimate: n.tokenEstimate,
|
|
109
|
+
},
|
|
110
|
+
]),
|
|
111
|
+
),
|
|
112
|
+
rootId: nodes.reduce<typeof nodes[number] | null>((best, n) => (!best || n.level > (best?.level ?? -1) ? n : best), null)?.id ?? null,
|
|
113
|
+
levels: Math.max(1, ...nodes.map((n) => n.level + 1)),
|
|
114
|
+
timedOut: false,
|
|
115
|
+
};
|
|
116
|
+
void byId;
|
|
117
|
+
return stagedExpansion(query, tree, { embedder, k: opts.k, topM: opts.topM });
|
|
118
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
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
|
+
|
|
12
|
+
import type { Vector } from "../../embedder.js";
|
|
13
|
+
import { cosineSimilarity } from "../../embedder.js";
|
|
14
|
+
|
|
15
|
+
/** Seeded PRNG (mulberry32) so clustering is deterministic across runs. */
|
|
16
|
+
function rng(seed: number): () => number {
|
|
17
|
+
let a = seed >>> 0;
|
|
18
|
+
return () => {
|
|
19
|
+
a |= 0;
|
|
20
|
+
a = (a + 0x6d2b79f5) | 0;
|
|
21
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
22
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
23
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** L2 distance squared between two equal-length vectors. */
|
|
28
|
+
function dist2(a: Vector, b: Vector): number {
|
|
29
|
+
let s = 0;
|
|
30
|
+
for (let i = 0; i < a.length; i++) {
|
|
31
|
+
const d = a[i] - b[i];
|
|
32
|
+
s += d * d;
|
|
33
|
+
}
|
|
34
|
+
return s;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface KMeansResult {
|
|
38
|
+
/** Cluster assignment per input point (index → cluster id). */
|
|
39
|
+
assignments: number[];
|
|
40
|
+
/** Centroids, one per cluster. */
|
|
41
|
+
centroids: Vector[];
|
|
42
|
+
/** Number of clusters actually produced (may be < k for degenerate input). */
|
|
43
|
+
k: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* k-means++ clustering. Deterministic given `seed`.
|
|
48
|
+
*
|
|
49
|
+
* Near-zero-variance guard (QA #11): if the max pairwise distance among input
|
|
50
|
+
* points is below `varianceFloor`, all points are effectively identical — return
|
|
51
|
+
* a single cluster (the mean) instead of iterating. Prevents the centroid-init
|
|
52
|
+
* from dividing by ~0 and keeps degenerate sessions from spinning.
|
|
53
|
+
*/
|
|
54
|
+
export function kmeanspp(
|
|
55
|
+
points: Vector[],
|
|
56
|
+
k: number,
|
|
57
|
+
opts: { seed?: number; maxIter?: number; varianceFloor?: number } = {},
|
|
58
|
+
): KMeansResult {
|
|
59
|
+
const n = points.length;
|
|
60
|
+
const seed = opts.seed ?? 0x9e3779b9;
|
|
61
|
+
const maxIter = opts.maxIter ?? 25;
|
|
62
|
+
const varianceFloor = opts.varianceFloor ?? 1e-12;
|
|
63
|
+
|
|
64
|
+
if (n === 0) return { assignments: [], centroids: [], k: 0 };
|
|
65
|
+
if (n === 1) return { assignments: [0], centroids: [points[0].slice()], k: 1 };
|
|
66
|
+
|
|
67
|
+
// Near-zero-variance merge guard.
|
|
68
|
+
let maxPair = 0;
|
|
69
|
+
for (let i = 0; i < n; i++) {
|
|
70
|
+
for (let j = i + 1; j < n; j++) {
|
|
71
|
+
const d = dist2(points[i], points[j]);
|
|
72
|
+
if (d > maxPair) maxPair = d;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (maxPair < varianceFloor) {
|
|
76
|
+
const mean = new Array<number>(points[0].length).fill(0);
|
|
77
|
+
for (const p of points) for (let i = 0; i < mean.length; i++) mean[i] += p[i] / n;
|
|
78
|
+
return { assignments: new Array<number>(n).fill(0), centroids: [mean], k: 1 };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const kk = Math.min(k, n);
|
|
82
|
+
const rand = rng(seed);
|
|
83
|
+
|
|
84
|
+
// k-means++ seeding: first centroid random, rest weighted by squared distance.
|
|
85
|
+
const centroids: Vector[] = [points[Math.floor(rand() * n)].slice()];
|
|
86
|
+
const d2 = new Array<number>(n).fill(Infinity);
|
|
87
|
+
while (centroids.length < kk) {
|
|
88
|
+
let sum = 0;
|
|
89
|
+
for (let i = 0; i < n; i++) {
|
|
90
|
+
const d = dist2(points[i], centroids[centroids.length - 1]);
|
|
91
|
+
if (d < d2[i]) d2[i] = d;
|
|
92
|
+
sum += d2[i];
|
|
93
|
+
}
|
|
94
|
+
if (sum === 0) {
|
|
95
|
+
// All remaining points coincide with an existing centroid.
|
|
96
|
+
for (const p of points) if (!centroids.some((c) => dist2(c, p) < 1e-12)) centroids.push(p.slice());
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
let target = rand() * sum;
|
|
100
|
+
let chosen = 0;
|
|
101
|
+
for (let i = 0; i < n; i++) {
|
|
102
|
+
target -= d2[i];
|
|
103
|
+
if (target <= 0) { chosen = i; break; }
|
|
104
|
+
}
|
|
105
|
+
centroids.push(points[chosen].slice());
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const assignments = new Array<number>(n).fill(0);
|
|
109
|
+
for (let iter = 0; iter < maxIter; iter++) {
|
|
110
|
+
let changed = false;
|
|
111
|
+
// Assign each point to its nearest centroid.
|
|
112
|
+
for (let i = 0; i < n; i++) {
|
|
113
|
+
let best = 0;
|
|
114
|
+
let bestD = Infinity;
|
|
115
|
+
for (let c = 0; c < centroids.length; c++) {
|
|
116
|
+
const d = dist2(points[i], centroids[c]);
|
|
117
|
+
if (d < bestD) { bestD = d; best = c; }
|
|
118
|
+
}
|
|
119
|
+
if (assignments[i] !== best) { assignments[i] = best; changed = true; }
|
|
120
|
+
}
|
|
121
|
+
// Recompute centroids as the mean of assigned points.
|
|
122
|
+
const sums = centroids.map(() => new Array<number>(points[0].length).fill(0));
|
|
123
|
+
const counts = new Array<number>(centroids.length).fill(0);
|
|
124
|
+
for (let i = 0; i < n; i++) {
|
|
125
|
+
const c = assignments[i];
|
|
126
|
+
counts[c]++;
|
|
127
|
+
for (let d = 0; d < points[i].length; d++) sums[c][d] += points[i][d];
|
|
128
|
+
}
|
|
129
|
+
for (let c = 0; c < centroids.length; c++) {
|
|
130
|
+
if (counts[c] === 0) continue; // keep prior centroid if a cluster emptied
|
|
131
|
+
centroids[c] = sums[c].map((s) => s / counts[c]);
|
|
132
|
+
}
|
|
133
|
+
if (!changed && iter > 0) break;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { assignments, centroids, k: centroids.length };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Mean embedding of a set of vectors (used to reduce a cluster to its centroid). */
|
|
140
|
+
export function meanVector(vectors: Vector[]): Vector {
|
|
141
|
+
if (vectors.length === 0) return [];
|
|
142
|
+
const dim = vectors[0].length;
|
|
143
|
+
const sum = new Array<number>(dim).fill(0);
|
|
144
|
+
for (const v of vectors) for (let i = 0; i < dim; i++) sum[i] += v[i];
|
|
145
|
+
return sum.map((s) => s / vectors.length);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Centroid of the union of two clusters (cosine-space "midpoint" via mean). */
|
|
149
|
+
export function mergeCentroids(a: Vector, b: Vector): Vector {
|
|
150
|
+
return meanVector([a, b]);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Cosine distance (1 - cosine) — used by tree.ts for budget/quality checks. */
|
|
154
|
+
export function cosineDistance(a: Vector, b: Vector): number {
|
|
155
|
+
return 1 - cosineSimilarity(a, b);
|
|
156
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
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
|
+
|
|
10
|
+
import { test } from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { TrigramEmbedder } from "../../embedder.js";
|
|
16
|
+
import { kmeanspp } from "./kmeans.js";
|
|
17
|
+
import {
|
|
18
|
+
applyHallucinationGuardrails,
|
|
19
|
+
sourceTokenSet,
|
|
20
|
+
extractEntities,
|
|
21
|
+
makeUngroundedSummary,
|
|
22
|
+
} from "./guardrails.js";
|
|
23
|
+
import { buildRaptorTree, type Leaf } from "./tree.js";
|
|
24
|
+
import { stagedExpansion } from "./retrieval.js";
|
|
25
|
+
import { runRaptor } from "./index.js";
|
|
26
|
+
import { Logger } from "../../log.js";
|
|
27
|
+
import {
|
|
28
|
+
listRaptorNodes,
|
|
29
|
+
clearRaptorNodes,
|
|
30
|
+
closeStore,
|
|
31
|
+
} from "../../store/sqlite.js";
|
|
32
|
+
import type { EngineMessage } from "../../types.js";
|
|
33
|
+
|
|
34
|
+
const baseTmp = mkdtempSync(join(tmpdir(), "mc-raptor-"));
|
|
35
|
+
|
|
36
|
+
function msg(text: string): EngineMessage {
|
|
37
|
+
return { role: "user", text };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Build N distinct leaves with deterministic content. */
|
|
41
|
+
function makeLeaves(n: number, embedder = new TrigramEmbedder()): Leaf[] {
|
|
42
|
+
const leaves: Leaf[] = [];
|
|
43
|
+
for (let i = 0; i < n; i++) {
|
|
44
|
+
const text = `topic ${i % 7}: the module ${i} validated the session token and refreshed the cache for region ${i}`;
|
|
45
|
+
leaves.push({
|
|
46
|
+
id: `leaf_${i}`,
|
|
47
|
+
messages: [msg(text)],
|
|
48
|
+
sourceText: text,
|
|
49
|
+
embedding: embedder.embed(text),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return leaves;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// --- kmeans: near-zero-variance merge guard ---------------------------------
|
|
56
|
+
|
|
57
|
+
test("kmeanspp merges identical points into a single cluster (QA #11)", () => {
|
|
58
|
+
const p = [1, 0, 0];
|
|
59
|
+
const points = [p, p, p, p, p];
|
|
60
|
+
const r = kmeanspp(points, 3, { seed: 1 });
|
|
61
|
+
assert.equal(r.k, 1);
|
|
62
|
+
assert.deepEqual(r.assignments, [0, 0, 0, 0, 0]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("kmeanspp separates two well-separated clusters", () => {
|
|
66
|
+
const a = [1, 0, 0];
|
|
67
|
+
const b = [0, 1, 0];
|
|
68
|
+
const points = [a, a, a, b, b, b];
|
|
69
|
+
const r = kmeanspp(points, 2, { seed: 7 });
|
|
70
|
+
assert.equal(r.k, 2);
|
|
71
|
+
// All the a's share one assignment, all the b's another.
|
|
72
|
+
const firstGroup = r.assignments[0];
|
|
73
|
+
const secondGroup = r.assignments[3];
|
|
74
|
+
assert.notEqual(firstGroup, secondGroup);
|
|
75
|
+
for (let i = 0; i < 3; i++) assert.equal(r.assignments[i], firstGroup);
|
|
76
|
+
for (let i = 3; i < 6; i++) assert.equal(r.assignments[i], secondGroup);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// --- guardrails: catch hallucination (QA #16) ------------------------------
|
|
80
|
+
|
|
81
|
+
test("guardrails flag an un-grounded entity as extractive_fallback", () => {
|
|
82
|
+
const embedder = new TrigramEmbedder();
|
|
83
|
+
const realSource = "the auth module validates the session token";
|
|
84
|
+
const summary = makeUngroundedSummary(realSource, "ZkPhant0mCorp");
|
|
85
|
+
const sources = [realSource];
|
|
86
|
+
const r = applyHallucinationGuardrails({
|
|
87
|
+
summary,
|
|
88
|
+
sources,
|
|
89
|
+
centroid: embedder.embed(realSource),
|
|
90
|
+
embedder,
|
|
91
|
+
sourceTokens: sourceTokenSet(sources),
|
|
92
|
+
});
|
|
93
|
+
assert.equal(r.marker, "extractive_fallback");
|
|
94
|
+
assert.equal(r.grounded, false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("guardrails pass a faithful, grounded summary", () => {
|
|
98
|
+
const embedder = new TrigramEmbedder();
|
|
99
|
+
const src = "the auth module validates the session token and refreshes the cache";
|
|
100
|
+
const summary = "the auth module validates the session token";
|
|
101
|
+
const sources = [src];
|
|
102
|
+
const r = applyHallucinationGuardrails({
|
|
103
|
+
summary,
|
|
104
|
+
sources,
|
|
105
|
+
centroid: embedder.embed(src),
|
|
106
|
+
embedder,
|
|
107
|
+
sourceTokens: sourceTokenSet(sources),
|
|
108
|
+
});
|
|
109
|
+
assert.equal(r.grounded, true);
|
|
110
|
+
assert.ok(r.marker === "high" || r.marker === "low");
|
|
111
|
+
assert.notEqual(r.marker, "extractive_fallback");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("extractEntities lowercases candidate tokens", () => {
|
|
115
|
+
const e = extractEntities("The AuthModule validates the token_abc 42 times");
|
|
116
|
+
assert.ok(e.includes("authmodule"));
|
|
117
|
+
assert.ok(e.includes("token_abc"));
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// --- tree: <10 leaves → single node -----------------------------------------
|
|
121
|
+
|
|
122
|
+
test("tree with <10 leaves yields a single root node", () => {
|
|
123
|
+
const leaves = makeLeaves(5);
|
|
124
|
+
const tree = buildRaptorTree(leaves, { embedder: new TrigramEmbedder() });
|
|
125
|
+
assert.equal(tree.nodes.size, 1);
|
|
126
|
+
assert.equal(tree.rootId, "r0_0");
|
|
127
|
+
assert.equal(tree.levels, 1);
|
|
128
|
+
assert.equal(tree.timedOut, false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// --- tree: 1K fixture builds within budget ----------------------------------
|
|
132
|
+
|
|
133
|
+
test("tree builds within 5s on a 1000-leaf fixture", () => {
|
|
134
|
+
const leaves = makeLeaves(1000);
|
|
135
|
+
const t0 = Date.now();
|
|
136
|
+
const tree = buildRaptorTree(leaves, {
|
|
137
|
+
embedder: new TrigramEmbedder(),
|
|
138
|
+
budgetMs: 5000,
|
|
139
|
+
clustersPerLevel: 8,
|
|
140
|
+
});
|
|
141
|
+
const elapsed = Date.now() - t0;
|
|
142
|
+
assert.ok(elapsed < 5000, `build took ${elapsed}ms (over 5s budget)`);
|
|
143
|
+
assert.ok(tree.nodes.size > 1, "expected a multi-level tree");
|
|
144
|
+
assert.ok(tree.rootId !== null);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// --- tree: budget timeout → extractive fallback root ------------------------
|
|
148
|
+
|
|
149
|
+
test("tree respects the budget: a tiny budget forces an extractive fallback root", () => {
|
|
150
|
+
const leaves = makeLeaves(200);
|
|
151
|
+
const tree = buildRaptorTree(leaves, {
|
|
152
|
+
embedder: new TrigramEmbedder(),
|
|
153
|
+
budgetMs: 0, // forces immediate timeout on the first level
|
|
154
|
+
clustersPerLevel: 4,
|
|
155
|
+
});
|
|
156
|
+
assert.equal(tree.timedOut, true);
|
|
157
|
+
assert.equal(tree.rootId, "r99_0");
|
|
158
|
+
assert.ok(tree.nodes.has("r99_0"));
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// --- retrieval: staged expansion returns leaf ids ---------------------------
|
|
162
|
+
|
|
163
|
+
test("stagedExpansion returns diversified leaf ids for a query", () => {
|
|
164
|
+
const leaves = makeLeaves(20);
|
|
165
|
+
const tree = buildRaptorTree(leaves, { embedder: new TrigramEmbedder(), clustersPerLevel: 4 });
|
|
166
|
+
const hits = stagedExpansion("the auth module validates the session token", tree, {
|
|
167
|
+
embedder: new TrigramEmbedder(),
|
|
168
|
+
k: 3,
|
|
169
|
+
topM: 3,
|
|
170
|
+
});
|
|
171
|
+
assert.ok(hits.length > 0);
|
|
172
|
+
assert.ok(hits.length <= 3);
|
|
173
|
+
// All returned ids are raw leaf ids (not internal node ids).
|
|
174
|
+
for (const id of hits) assert.ok(id.startsWith("leaf_"));
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// --- shadow mode: builds + persists, never alters retrieval -----------------
|
|
178
|
+
|
|
179
|
+
test("runRaptor builds + persists a shadow tree without throwing", () => {
|
|
180
|
+
const dir = join(baseTmp, `shadow-${Math.floor(performance.now())}`);
|
|
181
|
+
const leaves = makeLeaves(40);
|
|
182
|
+
const logger = new Logger({
|
|
183
|
+
enabled: true,
|
|
184
|
+
now: () => 0,
|
|
185
|
+
path: join(dir, "events.log"),
|
|
186
|
+
});
|
|
187
|
+
const tree = runRaptor(leaves, {
|
|
188
|
+
stateDir: dir,
|
|
189
|
+
sessionId: "sess_raptor",
|
|
190
|
+
embedder: new TrigramEmbedder(),
|
|
191
|
+
logger,
|
|
192
|
+
});
|
|
193
|
+
assert.ok(tree !== null);
|
|
194
|
+
// Tree was persisted to raptor_nodes.
|
|
195
|
+
const stored = listRaptorNodes("sess_raptor", dir);
|
|
196
|
+
assert.ok(stored.length > 0, "shadow tree should be persisted");
|
|
197
|
+
// Quality markers are valid values.
|
|
198
|
+
for (const n of stored) {
|
|
199
|
+
assert.ok(["high", "low", "extractive_fallback"].includes(n.qualityMarker) || n.qualityMarker === "low");
|
|
200
|
+
}
|
|
201
|
+
clearRaptorNodes("sess_raptor", dir);
|
|
202
|
+
closeStore(dir);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("shadow mode does NOT change recallAndInline output (separation)", () => {
|
|
206
|
+
// The orchestrator persists to raptor_nodes only. The live store's
|
|
207
|
+
// recallAndInline path (vectorStore.search) reads context_chunks, never
|
|
208
|
+
// raptor_nodes, so building a RAPTOR tree is observably independent.
|
|
209
|
+
const dir = join(baseTmp, `sep-${Math.floor(performance.now())}`);
|
|
210
|
+
const leaves = makeLeaves(15);
|
|
211
|
+
const before = listRaptorNodes("sess_sep", dir);
|
|
212
|
+
runRaptor(leaves, {
|
|
213
|
+
stateDir: dir,
|
|
214
|
+
sessionId: "sess_sep",
|
|
215
|
+
embedder: new TrigramEmbedder(),
|
|
216
|
+
});
|
|
217
|
+
const after = listRaptorNodes("sess_sep", dir);
|
|
218
|
+
// Building RAPTOR only ever writes raptor_nodes; context_chunks is untouched.
|
|
219
|
+
assert.deepEqual(before, []);
|
|
220
|
+
assert.ok(after.length > 0);
|
|
221
|
+
clearRaptorNodes("sess_sep", dir);
|
|
222
|
+
closeStore(dir);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// --- eval: redundancy reduction ≥ 15% (nodes << leaves) ---------------------
|
|
226
|
+
|
|
227
|
+
test("eval: RAPTOR reduces node count substantially vs flat (≥15%)", () => {
|
|
228
|
+
const leaves = makeLeaves(100);
|
|
229
|
+
const tree = buildRaptorTree(leaves, { embedder: new TrigramEmbedder(), clustersPerLevel: 8 });
|
|
230
|
+
const reduction = 1 - tree.nodes.size / leaves.length;
|
|
231
|
+
assert.ok(reduction >= 0.15, `redundancy reduction ${reduction.toFixed(2)} < 0.15`);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// --- cleanup ----------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
test("RAPTOR cleanup", () => {
|
|
237
|
+
rmSync(baseTmp, { recursive: true, force: true });
|
|
238
|
+
});
|