@crewhaus/compaction-curator 0.1.4 → 0.1.5
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/index.d.ts +123 -0
- package/dist/index.js +199 -0
- package/package.json +9 -6
- package/src/index.test.ts +0 -421
- package/src/index.ts +0 -288
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pillar 2 active context curation — pre-compaction relevance + dedupe.
|
|
3
|
+
*
|
|
4
|
+
* `compaction-autocompact` (model summarize-then-replace) and
|
|
5
|
+
* `compaction-snip` (drop oldest) are *reactive*: they only fire when
|
|
6
|
+
* context is already near its limit, and they cost a model call (autocompact)
|
|
7
|
+
* or accept information loss (snip). Routray's "Stop Wasting Money on AI
|
|
8
|
+
* Context You Don't Need" (Mar 2026) documents 60–80% token reduction
|
|
9
|
+
* from a pre-compaction pass that:
|
|
10
|
+
*
|
|
11
|
+
* 1. Removes semantically duplicate items (embedding cosine ≥ threshold).
|
|
12
|
+
* 2. Re-orders to front-load high-relevance items (since transformer
|
|
13
|
+
* attention favors prompt-start and prompt-end positions).
|
|
14
|
+
*
|
|
15
|
+
* This package implements both as pure functions over a generic `Item`
|
|
16
|
+
* shape (string text + optional embedding). Callers (compaction-autocompact
|
|
17
|
+
* primarily) wire it as an opt-in stage controlled by `spec.compaction.curate`.
|
|
18
|
+
* When curation brings the item count below the model-summarize trigger,
|
|
19
|
+
* the summarization model call is skipped entirely — that's where the
|
|
20
|
+
* cost savings compound on top of token savings.
|
|
21
|
+
*
|
|
22
|
+
* The package does NOT carry an embedder dependency. Callers supply
|
|
23
|
+
* `EmbedderFn` — typically wrapping `@crewhaus/embedder`'s configured
|
|
24
|
+
* provider. This keeps `compaction-curator` light enough to be useful
|
|
25
|
+
* from `target-pipeline` (which already has an embedder) without forcing
|
|
26
|
+
* a heavy import onto `target-cli` (which doesn't).
|
|
27
|
+
*
|
|
28
|
+
* Catalog layer: R6 (extension of §17 compaction primitives, symmetric to
|
|
29
|
+
* `compaction-autocompact` and `compaction-snip`). Recipe:
|
|
30
|
+
* demos/walkthroughs/52-context-curation.md.
|
|
31
|
+
*/
|
|
32
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
33
|
+
export declare class CompactionCuratorError extends CrewhausError {
|
|
34
|
+
readonly name = "CompactionCuratorError";
|
|
35
|
+
constructor(message: string, cause?: unknown);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The generic shape this package operates on. Sufficient for two backends:
|
|
39
|
+
* - context items: `text` = message body, `embedding` = optional pre-
|
|
40
|
+
* computed vector
|
|
41
|
+
* - RAG-retrieved chunks: `text` = chunk body, `embedding` = the vector
|
|
42
|
+
* already used for retrieval
|
|
43
|
+
*/
|
|
44
|
+
export type Item = {
|
|
45
|
+
readonly text: string;
|
|
46
|
+
/** Optional pre-computed embedding. When absent, callers must supply
|
|
47
|
+
* `EmbedderFn` to `curate()`. */
|
|
48
|
+
readonly embedding?: ReadonlyArray<number>;
|
|
49
|
+
/** Optional client-side identifier carried through unchanged. */
|
|
50
|
+
readonly id?: string;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Async embedder callback. Returns a vector per input string in the same
|
|
54
|
+
* order. Implementations typically batch internally for cost efficiency.
|
|
55
|
+
*
|
|
56
|
+
* Vectors must be normalized (unit length) so cosine reduces to a dot
|
|
57
|
+
* product. Most production embedders return normalized vectors by default;
|
|
58
|
+
* if yours doesn't, normalize before returning here.
|
|
59
|
+
*/
|
|
60
|
+
export type EmbedderFn = (texts: ReadonlyArray<string>) => Promise<ReadonlyArray<ReadonlyArray<number>>>;
|
|
61
|
+
export type CurateOptions = {
|
|
62
|
+
/**
|
|
63
|
+
* Query / goal string used to score relevance. When absent, the curator
|
|
64
|
+
* skips the relevance-reorder pass and only does dedupe.
|
|
65
|
+
*/
|
|
66
|
+
readonly query?: string;
|
|
67
|
+
/**
|
|
68
|
+
* Cosine-similarity threshold above which two items are considered
|
|
69
|
+
* duplicates. Default 0.92 — high enough to avoid false-positive
|
|
70
|
+
* collapses on near-but-distinct items (a function definition and its
|
|
71
|
+
* caller share substantial token overlap but should NOT dedupe).
|
|
72
|
+
*/
|
|
73
|
+
readonly dedupeThreshold?: number;
|
|
74
|
+
/**
|
|
75
|
+
* Max items to keep after relevance scoring. Items below this rank are
|
|
76
|
+
* dropped entirely. When absent, no items are dropped from the
|
|
77
|
+
* relevance pass — items are only reordered. Set when you want a hard
|
|
78
|
+
* top-K filter.
|
|
79
|
+
*/
|
|
80
|
+
readonly relevanceTopK?: number;
|
|
81
|
+
/**
|
|
82
|
+
* Embedder supplied by the caller. Required if any item lacks a
|
|
83
|
+
* pre-computed `embedding` AND either dedupe or relevance reorder is
|
|
84
|
+
* requested. The function-level types let it stay optional for the
|
|
85
|
+
* pre-embedded case (most production callers).
|
|
86
|
+
*/
|
|
87
|
+
readonly embedder?: EmbedderFn;
|
|
88
|
+
};
|
|
89
|
+
export declare const DEFAULT_DEDUPE_THRESHOLD = 0.92;
|
|
90
|
+
/**
|
|
91
|
+
* Pass 1 — semantic dedupe. For each item, if a *prior* item's embedding
|
|
92
|
+
* has cosine ≥ threshold, drop the current. Order-preserving: the first
|
|
93
|
+
* occurrence wins (keeps the head of the conversation, which carries
|
|
94
|
+
* goal-setting context).
|
|
95
|
+
*/
|
|
96
|
+
export declare function dedupeBySimilarity(items: ReadonlyArray<Item>, embeddings: ReadonlyArray<ReadonlyArray<number>>, threshold: number): {
|
|
97
|
+
kept: ReadonlyArray<number>;
|
|
98
|
+
dropped: ReadonlyArray<number>;
|
|
99
|
+
};
|
|
100
|
+
/**
|
|
101
|
+
* Pass 2 — relevance reorder. Compute cosine(query, item) for every
|
|
102
|
+
* surviving item; return indices in descending similarity order.
|
|
103
|
+
* Stable sort: items with identical similarity stay in original order.
|
|
104
|
+
*
|
|
105
|
+
* When `topK` is supplied, items beyond that rank are dropped.
|
|
106
|
+
*/
|
|
107
|
+
export declare function rankByRelevance(items: ReadonlyArray<Item>, itemEmbeddings: ReadonlyArray<ReadonlyArray<number>>, query: string, embedder: EmbedderFn | undefined, topK?: number): Promise<ReadonlyArray<number>>;
|
|
108
|
+
export type CurationResult = {
|
|
109
|
+
/** Items in the curated order (deduped + relevance-ranked + topK-trimmed). */
|
|
110
|
+
readonly items: ReadonlyArray<Item>;
|
|
111
|
+
/** Indices into the *original* input array, in output order. */
|
|
112
|
+
readonly originalIndices: ReadonlyArray<number>;
|
|
113
|
+
/** Indices that were dropped (in original order). */
|
|
114
|
+
readonly droppedIndices: ReadonlyArray<number>;
|
|
115
|
+
/** Bytes saved as compared to the original total (text only). */
|
|
116
|
+
readonly bytesSaved: number;
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* High-level entry: dedupe then relevance-reorder (when a query is
|
|
120
|
+
* supplied) then top-K filter. The most common call from
|
|
121
|
+
* `compaction-autocompact` is `curate(items, { query, embedder })`.
|
|
122
|
+
*/
|
|
123
|
+
export declare function curate(items: ReadonlyArray<Item>, opts?: CurateOptions): Promise<CurationResult>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pillar 2 active context curation — pre-compaction relevance + dedupe.
|
|
3
|
+
*
|
|
4
|
+
* `compaction-autocompact` (model summarize-then-replace) and
|
|
5
|
+
* `compaction-snip` (drop oldest) are *reactive*: they only fire when
|
|
6
|
+
* context is already near its limit, and they cost a model call (autocompact)
|
|
7
|
+
* or accept information loss (snip). Routray's "Stop Wasting Money on AI
|
|
8
|
+
* Context You Don't Need" (Mar 2026) documents 60–80% token reduction
|
|
9
|
+
* from a pre-compaction pass that:
|
|
10
|
+
*
|
|
11
|
+
* 1. Removes semantically duplicate items (embedding cosine ≥ threshold).
|
|
12
|
+
* 2. Re-orders to front-load high-relevance items (since transformer
|
|
13
|
+
* attention favors prompt-start and prompt-end positions).
|
|
14
|
+
*
|
|
15
|
+
* This package implements both as pure functions over a generic `Item`
|
|
16
|
+
* shape (string text + optional embedding). Callers (compaction-autocompact
|
|
17
|
+
* primarily) wire it as an opt-in stage controlled by `spec.compaction.curate`.
|
|
18
|
+
* When curation brings the item count below the model-summarize trigger,
|
|
19
|
+
* the summarization model call is skipped entirely — that's where the
|
|
20
|
+
* cost savings compound on top of token savings.
|
|
21
|
+
*
|
|
22
|
+
* The package does NOT carry an embedder dependency. Callers supply
|
|
23
|
+
* `EmbedderFn` — typically wrapping `@crewhaus/embedder`'s configured
|
|
24
|
+
* provider. This keeps `compaction-curator` light enough to be useful
|
|
25
|
+
* from `target-pipeline` (which already has an embedder) without forcing
|
|
26
|
+
* a heavy import onto `target-cli` (which doesn't).
|
|
27
|
+
*
|
|
28
|
+
* Catalog layer: R6 (extension of §17 compaction primitives, symmetric to
|
|
29
|
+
* `compaction-autocompact` and `compaction-snip`). Recipe:
|
|
30
|
+
* demos/walkthroughs/52-context-curation.md.
|
|
31
|
+
*/
|
|
32
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
33
|
+
export class CompactionCuratorError extends CrewhausError {
|
|
34
|
+
name = "CompactionCuratorError";
|
|
35
|
+
constructor(message, cause) {
|
|
36
|
+
super("config", message, cause);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export const DEFAULT_DEDUPE_THRESHOLD = 0.92;
|
|
40
|
+
function cosine(a, b) {
|
|
41
|
+
if (a.length !== b.length) {
|
|
42
|
+
throw new CompactionCuratorError(`embedding dimension mismatch (${a.length} vs ${b.length})`);
|
|
43
|
+
}
|
|
44
|
+
let dot = 0;
|
|
45
|
+
let na = 0;
|
|
46
|
+
let nb = 0;
|
|
47
|
+
for (let i = 0; i < a.length; i++) {
|
|
48
|
+
const av = a[i] ?? 0;
|
|
49
|
+
const bv = b[i] ?? 0;
|
|
50
|
+
dot += av * bv;
|
|
51
|
+
na += av * av;
|
|
52
|
+
nb += bv * bv;
|
|
53
|
+
}
|
|
54
|
+
if (na === 0 || nb === 0)
|
|
55
|
+
return 0;
|
|
56
|
+
return dot / Math.sqrt(na * nb);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Ensure every input item has an embedding. Items already carrying one
|
|
60
|
+
* pass through; items missing one are batched into a single embedder
|
|
61
|
+
* call. Preserves order so the caller's downstream indexing isn't
|
|
62
|
+
* disturbed.
|
|
63
|
+
*/
|
|
64
|
+
async function ensureEmbeddings(items, embedder) {
|
|
65
|
+
if (items.length === 0)
|
|
66
|
+
return [];
|
|
67
|
+
const missingIdx = [];
|
|
68
|
+
const missingTexts = [];
|
|
69
|
+
for (let i = 0; i < items.length; i++) {
|
|
70
|
+
const item = items[i];
|
|
71
|
+
if (item !== undefined && item.embedding === undefined) {
|
|
72
|
+
missingIdx.push(i);
|
|
73
|
+
missingTexts.push(item.text);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (missingIdx.length > 0 && embedder === undefined) {
|
|
77
|
+
throw new CompactionCuratorError(`${missingIdx.length} item(s) lack pre-computed embeddings and no embedder was supplied — pass options.embedder or pre-embed your items`);
|
|
78
|
+
}
|
|
79
|
+
const fresh = embedder !== undefined && missingIdx.length > 0 ? await embedder(missingTexts) : [];
|
|
80
|
+
return items.map((item, i) => {
|
|
81
|
+
if (item.embedding !== undefined)
|
|
82
|
+
return item.embedding;
|
|
83
|
+
const slot = missingIdx.indexOf(i);
|
|
84
|
+
if (slot === -1) {
|
|
85
|
+
throw new CompactionCuratorError(`internal: missing embedding for item ${i}`);
|
|
86
|
+
}
|
|
87
|
+
const v = fresh[slot];
|
|
88
|
+
if (v === undefined) {
|
|
89
|
+
throw new CompactionCuratorError(`embedder returned no vector for item ${i}`);
|
|
90
|
+
}
|
|
91
|
+
return v;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Pass 1 — semantic dedupe. For each item, if a *prior* item's embedding
|
|
96
|
+
* has cosine ≥ threshold, drop the current. Order-preserving: the first
|
|
97
|
+
* occurrence wins (keeps the head of the conversation, which carries
|
|
98
|
+
* goal-setting context).
|
|
99
|
+
*/
|
|
100
|
+
export function dedupeBySimilarity(items, embeddings, threshold) {
|
|
101
|
+
const kept = [];
|
|
102
|
+
const dropped = [];
|
|
103
|
+
for (let i = 0; i < items.length; i++) {
|
|
104
|
+
let isDuplicate = false;
|
|
105
|
+
for (const k of kept) {
|
|
106
|
+
const e1 = embeddings[i];
|
|
107
|
+
const e2 = embeddings[k];
|
|
108
|
+
if (e1 === undefined || e2 === undefined)
|
|
109
|
+
continue;
|
|
110
|
+
if (cosine(e1, e2) >= threshold) {
|
|
111
|
+
isDuplicate = true;
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (isDuplicate)
|
|
116
|
+
dropped.push(i);
|
|
117
|
+
else
|
|
118
|
+
kept.push(i);
|
|
119
|
+
}
|
|
120
|
+
return { kept, dropped };
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Pass 2 — relevance reorder. Compute cosine(query, item) for every
|
|
124
|
+
* surviving item; return indices in descending similarity order.
|
|
125
|
+
* Stable sort: items with identical similarity stay in original order.
|
|
126
|
+
*
|
|
127
|
+
* When `topK` is supplied, items beyond that rank are dropped.
|
|
128
|
+
*/
|
|
129
|
+
export async function rankByRelevance(items, itemEmbeddings, query, embedder, topK) {
|
|
130
|
+
if (items.length === 0)
|
|
131
|
+
return [];
|
|
132
|
+
if (embedder === undefined) {
|
|
133
|
+
throw new CompactionCuratorError("rankByRelevance requires an embedder to compute the query vector");
|
|
134
|
+
}
|
|
135
|
+
const queryVecs = await embedder([query]);
|
|
136
|
+
const queryVec = queryVecs[0];
|
|
137
|
+
if (queryVec === undefined) {
|
|
138
|
+
throw new CompactionCuratorError("embedder returned no vector for the query");
|
|
139
|
+
}
|
|
140
|
+
const scored = [];
|
|
141
|
+
for (let i = 0; i < items.length; i++) {
|
|
142
|
+
const e = itemEmbeddings[i];
|
|
143
|
+
if (e === undefined)
|
|
144
|
+
continue;
|
|
145
|
+
scored.push({ idx: i, sim: cosine(queryVec, e), original: i });
|
|
146
|
+
}
|
|
147
|
+
scored.sort((a, b) => {
|
|
148
|
+
if (a.sim !== b.sim)
|
|
149
|
+
return b.sim - a.sim;
|
|
150
|
+
return a.original - b.original; // stable
|
|
151
|
+
});
|
|
152
|
+
const ordered = scored.map((s) => s.idx);
|
|
153
|
+
if (topK !== undefined && ordered.length > topK)
|
|
154
|
+
return ordered.slice(0, topK);
|
|
155
|
+
return ordered;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* High-level entry: dedupe then relevance-reorder (when a query is
|
|
159
|
+
* supplied) then top-K filter. The most common call from
|
|
160
|
+
* `compaction-autocompact` is `curate(items, { query, embedder })`.
|
|
161
|
+
*/
|
|
162
|
+
export async function curate(items, opts = {}) {
|
|
163
|
+
if (items.length === 0) {
|
|
164
|
+
return { items: [], originalIndices: [], droppedIndices: [], bytesSaved: 0 };
|
|
165
|
+
}
|
|
166
|
+
const threshold = opts.dedupeThreshold ?? DEFAULT_DEDUPE_THRESHOLD;
|
|
167
|
+
const embeddings = await ensureEmbeddings(items, opts.embedder);
|
|
168
|
+
const { kept, dropped } = dedupeBySimilarity(items, embeddings, threshold);
|
|
169
|
+
let surviving = kept;
|
|
170
|
+
const droppedAll = [...dropped];
|
|
171
|
+
if (opts.query !== undefined && opts.query.length > 0) {
|
|
172
|
+
const subItems = surviving.map((i) => items[i]).filter((it) => it !== undefined);
|
|
173
|
+
const subEmbeddings = surviving
|
|
174
|
+
.map((i) => embeddings[i])
|
|
175
|
+
.filter((e) => e !== undefined);
|
|
176
|
+
const localOrder = await rankByRelevance(subItems, subEmbeddings, opts.query, opts.embedder, opts.relevanceTopK);
|
|
177
|
+
const localToOriginal = surviving;
|
|
178
|
+
surviving = localOrder
|
|
179
|
+
.map((localIdx) => localToOriginal[localIdx])
|
|
180
|
+
.filter((i) => i !== undefined);
|
|
181
|
+
if (opts.relevanceTopK !== undefined) {
|
|
182
|
+
const survivingSet = new Set(surviving);
|
|
183
|
+
for (const i of kept)
|
|
184
|
+
if (!survivingSet.has(i))
|
|
185
|
+
droppedAll.push(i);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const outItems = surviving.map((i) => items[i]).filter((it) => it !== undefined);
|
|
189
|
+
const droppedTotal = droppedAll
|
|
190
|
+
.map((i) => items[i])
|
|
191
|
+
.filter((it) => it !== undefined)
|
|
192
|
+
.reduce((acc, it) => acc + Buffer.byteLength(it.text, "utf8"), 0);
|
|
193
|
+
return {
|
|
194
|
+
items: outItems,
|
|
195
|
+
originalIndices: surviving,
|
|
196
|
+
droppedIndices: [...droppedAll].sort((a, b) => a - b),
|
|
197
|
+
bytesSaved: droppedTotal,
|
|
198
|
+
};
|
|
199
|
+
}
|
package/package.json
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/compaction-curator",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Pillar-2 active context curation — pre-compaction relevance + dedupe pass that lets compaction-autocompact skip the model call when curation alone brings tokens under the trigger threshold",
|
|
6
|
-
"main": "
|
|
7
|
-
"types": "
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
8
|
"exports": {
|
|
9
|
-
".":
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
10
13
|
},
|
|
11
14
|
"scripts": {
|
|
12
15
|
"test": "bun test src"
|
|
13
16
|
},
|
|
14
17
|
"dependencies": {
|
|
15
|
-
"@crewhaus/errors": "0.1.
|
|
18
|
+
"@crewhaus/errors": "0.1.5"
|
|
16
19
|
},
|
|
17
20
|
"license": "Apache-2.0",
|
|
18
21
|
"author": {
|
|
@@ -32,5 +35,5 @@
|
|
|
32
35
|
"publishConfig": {
|
|
33
36
|
"access": "public"
|
|
34
37
|
},
|
|
35
|
-
"files": ["
|
|
38
|
+
"files": ["dist", "README.md", "LICENSE", "NOTICE"]
|
|
36
39
|
}
|
package/src/index.test.ts
DELETED
|
@@ -1,421 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
3
|
-
CompactionCuratorError,
|
|
4
|
-
type EmbedderFn,
|
|
5
|
-
type Item,
|
|
6
|
-
curate,
|
|
7
|
-
dedupeBySimilarity,
|
|
8
|
-
rankByRelevance,
|
|
9
|
-
} from "./index";
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Tiny canned embedder for deterministic tests: maps each unique input
|
|
13
|
-
* string to a fixed unit vector. Same input → same vector → cosine of 1.
|
|
14
|
-
* Different input → orthogonal vector → cosine of 0.
|
|
15
|
-
*/
|
|
16
|
-
function makeCannedEmbedder(): { fn: EmbedderFn; counter: () => number } {
|
|
17
|
-
const cache = new Map<string, ReadonlyArray<number>>();
|
|
18
|
-
let calls = 0;
|
|
19
|
-
const dim = 16;
|
|
20
|
-
const fn: EmbedderFn = async (texts) => {
|
|
21
|
-
calls += 1;
|
|
22
|
-
return texts.map((t) => {
|
|
23
|
-
const cached = cache.get(t);
|
|
24
|
-
if (cached !== undefined) return cached;
|
|
25
|
-
const idx = cache.size % dim;
|
|
26
|
-
const v = new Array(dim).fill(0);
|
|
27
|
-
v[idx] = 1;
|
|
28
|
-
const arr = Object.freeze(v) as ReadonlyArray<number>;
|
|
29
|
-
cache.set(t, arr);
|
|
30
|
-
return arr;
|
|
31
|
-
});
|
|
32
|
-
};
|
|
33
|
-
return { fn, counter: () => calls };
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
describe("curate — happy path", () => {
|
|
37
|
-
test("returns input unchanged when no dedupe + no query", async () => {
|
|
38
|
-
const { fn } = makeCannedEmbedder();
|
|
39
|
-
const items: ReadonlyArray<Item> = [
|
|
40
|
-
{ text: "alpha gamma delta epsilon zeta" },
|
|
41
|
-
{ text: "beta theta iota kappa lambda" },
|
|
42
|
-
{ text: "rho sigma tau upsilon phi" },
|
|
43
|
-
];
|
|
44
|
-
const result = await curate(items, { embedder: fn });
|
|
45
|
-
expect(result.items.length).toBe(3);
|
|
46
|
-
expect(result.droppedIndices.length).toBe(0);
|
|
47
|
-
expect(result.bytesSaved).toBe(0);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
test("dedupes when two items embed identically", async () => {
|
|
51
|
-
const embedder: EmbedderFn = async (texts) =>
|
|
52
|
-
texts.map(() => [1, 0, 0, 0] as ReadonlyArray<number>);
|
|
53
|
-
const items: ReadonlyArray<Item> = [
|
|
54
|
-
{ text: "alpha gamma delta epsilon zeta one" },
|
|
55
|
-
{ text: "alpha gamma delta epsilon zeta two" },
|
|
56
|
-
{ text: "alpha gamma delta epsilon zeta three" },
|
|
57
|
-
];
|
|
58
|
-
const result = await curate(items, { embedder });
|
|
59
|
-
expect(result.items.length).toBe(1);
|
|
60
|
-
expect(result.droppedIndices).toEqual([1, 2]);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
test("keeps when below dedupe threshold", async () => {
|
|
64
|
-
const { fn } = makeCannedEmbedder();
|
|
65
|
-
const items: ReadonlyArray<Item> = [
|
|
66
|
-
{ text: "alpha gamma delta epsilon zeta" },
|
|
67
|
-
{ text: "beta theta iota kappa lambda" },
|
|
68
|
-
];
|
|
69
|
-
const result = await curate(items, { embedder: fn, dedupeThreshold: 0.99 });
|
|
70
|
-
expect(result.items.length).toBe(2);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
test("relevance-orders when query supplied", async () => {
|
|
74
|
-
const items: ReadonlyArray<Item> = [
|
|
75
|
-
{ text: "completely unrelated chitchat", id: "off-topic" },
|
|
76
|
-
{ text: "deep technical content about query", id: "on-topic" },
|
|
77
|
-
];
|
|
78
|
-
// Make on-topic share the query vector
|
|
79
|
-
const embedder: EmbedderFn = async (texts) => {
|
|
80
|
-
return texts.map((t) => {
|
|
81
|
-
if (t === "deep technical content about query" || t === "search query") {
|
|
82
|
-
return [1, 0, 0] as ReadonlyArray<number>;
|
|
83
|
-
}
|
|
84
|
-
return [0, 1, 0] as ReadonlyArray<number>;
|
|
85
|
-
});
|
|
86
|
-
};
|
|
87
|
-
const result = await curate(items, { embedder, query: "search query" });
|
|
88
|
-
expect(result.items[0]?.id).toBe("on-topic");
|
|
89
|
-
expect(result.items[1]?.id).toBe("off-topic");
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
test("topK trims after relevance reorder", async () => {
|
|
93
|
-
const items: ReadonlyArray<Item> = [
|
|
94
|
-
{ text: "irrelevant content one", id: "1" },
|
|
95
|
-
{ text: "highly relevant query match", id: "2" },
|
|
96
|
-
{ text: "irrelevant content two", id: "3" },
|
|
97
|
-
];
|
|
98
|
-
const embedder: EmbedderFn = async (texts) =>
|
|
99
|
-
texts.map((t) => {
|
|
100
|
-
if (t === "highly relevant query match" || t === "the search query") {
|
|
101
|
-
return [1, 0] as ReadonlyArray<number>;
|
|
102
|
-
}
|
|
103
|
-
return [0, 1] as ReadonlyArray<number>;
|
|
104
|
-
});
|
|
105
|
-
const result = await curate(items, {
|
|
106
|
-
embedder,
|
|
107
|
-
query: "the search query",
|
|
108
|
-
relevanceTopK: 1,
|
|
109
|
-
});
|
|
110
|
-
expect(result.items.length).toBe(1);
|
|
111
|
-
expect(result.items[0]?.id).toBe("2");
|
|
112
|
-
expect(result.droppedIndices.length).toBe(2);
|
|
113
|
-
});
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
describe("curate — pre-embedded path", () => {
|
|
117
|
-
test("no embedder needed when all items carry embeddings", async () => {
|
|
118
|
-
const items: ReadonlyArray<Item> = [
|
|
119
|
-
{ text: "first item with embedding precomputed", embedding: [1, 0] },
|
|
120
|
-
{ text: "second item with embedding precomputed", embedding: [0, 1] },
|
|
121
|
-
];
|
|
122
|
-
const result = await curate(items, {}); // no embedder, no query
|
|
123
|
-
expect(result.items.length).toBe(2);
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
test("throws when items lack embeddings and no embedder is supplied", async () => {
|
|
127
|
-
const items: ReadonlyArray<Item> = [{ text: "needs embedding plenty of words" }];
|
|
128
|
-
await expect(curate(items, {})).rejects.toThrow(/lack pre-computed embeddings/);
|
|
129
|
-
});
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
describe("curate — edge cases", () => {
|
|
133
|
-
test("empty input returns empty result", async () => {
|
|
134
|
-
const result = await curate([], {});
|
|
135
|
-
expect(result.items.length).toBe(0);
|
|
136
|
-
expect(result.bytesSaved).toBe(0);
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
test("bytesSaved reflects dropped item lengths", async () => {
|
|
140
|
-
const embedder: EmbedderFn = async (texts) => texts.map(() => [1, 0] as ReadonlyArray<number>);
|
|
141
|
-
const items: ReadonlyArray<Item> = [
|
|
142
|
-
{ text: "kept first kept first kept first" }, // 32 bytes
|
|
143
|
-
{ text: "dropped dropped dropped" }, // 23 bytes
|
|
144
|
-
];
|
|
145
|
-
const result = await curate(items, { embedder });
|
|
146
|
-
expect(result.bytesSaved).toBe(Buffer.byteLength("dropped dropped dropped", "utf8"));
|
|
147
|
-
});
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
describe("dedupeBySimilarity (pure)", () => {
|
|
151
|
-
test("first occurrence wins for duplicates", () => {
|
|
152
|
-
const items: ReadonlyArray<Item> = [{ text: "a" }, { text: "b" }, { text: "c" }];
|
|
153
|
-
const embeddings: ReadonlyArray<ReadonlyArray<number>> = [
|
|
154
|
-
[1, 0],
|
|
155
|
-
[1, 0], // duplicate of #0
|
|
156
|
-
[0, 1],
|
|
157
|
-
];
|
|
158
|
-
const r = dedupeBySimilarity(items, embeddings, 0.92);
|
|
159
|
-
expect(r.kept).toEqual([0, 2]);
|
|
160
|
-
expect(r.dropped).toEqual([1]);
|
|
161
|
-
});
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
describe("rankByRelevance (pure)", () => {
|
|
165
|
-
test("ranks by descending cosine similarity", async () => {
|
|
166
|
-
const items: ReadonlyArray<Item> = [{ text: "x" }, { text: "y" }];
|
|
167
|
-
const embeddings: ReadonlyArray<ReadonlyArray<number>> = [
|
|
168
|
-
[0, 1],
|
|
169
|
-
[1, 0],
|
|
170
|
-
];
|
|
171
|
-
const embedder: EmbedderFn = async () => [[1, 0]];
|
|
172
|
-
const order = await rankByRelevance(items, embeddings, "q", embedder);
|
|
173
|
-
expect(order).toEqual([1, 0]);
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
test("returns empty for empty items without calling the embedder", async () => {
|
|
177
|
-
let called = false;
|
|
178
|
-
const embedder: EmbedderFn = async (texts) => {
|
|
179
|
-
called = true;
|
|
180
|
-
return texts.map(() => [1, 0]);
|
|
181
|
-
};
|
|
182
|
-
const order = await rankByRelevance([], [], "q", embedder);
|
|
183
|
-
expect(order).toEqual([]);
|
|
184
|
-
expect(called).toBe(false);
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
test("throws when no embedder is supplied", async () => {
|
|
188
|
-
const items: ReadonlyArray<Item> = [{ text: "x" }];
|
|
189
|
-
await expect(rankByRelevance(items, [[1, 0]], "q", undefined)).rejects.toThrow(
|
|
190
|
-
/requires an embedder to compute the query vector/,
|
|
191
|
-
);
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
test("throws when the embedder returns no vector for the query", async () => {
|
|
195
|
-
const items: ReadonlyArray<Item> = [{ text: "x" }];
|
|
196
|
-
const embedder: EmbedderFn = async () => []; // empty -> queryVec is undefined
|
|
197
|
-
await expect(rankByRelevance(items, [[1, 0]], "q", embedder)).rejects.toThrow(
|
|
198
|
-
/returned no vector for the query/,
|
|
199
|
-
);
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
test("skips items whose embedding is missing from the embeddings array", async () => {
|
|
203
|
-
// Two items but only one embedding supplied: index 1 has no embedding and
|
|
204
|
-
// is skipped, so only index 0 is ranked/returned.
|
|
205
|
-
const items: ReadonlyArray<Item> = [{ text: "x" }, { text: "y" }];
|
|
206
|
-
const embeddings: ReadonlyArray<ReadonlyArray<number>> = [[1, 0]];
|
|
207
|
-
const embedder: EmbedderFn = async () => [[1, 0]];
|
|
208
|
-
const order = await rankByRelevance(items, embeddings, "q", embedder);
|
|
209
|
-
expect(order).toEqual([0]);
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
test("stable order is preserved for equal similarities", async () => {
|
|
213
|
-
// Both items orthogonal to the query -> identical similarity (0). Stable
|
|
214
|
-
// sort must keep original order [0, 1].
|
|
215
|
-
const items: ReadonlyArray<Item> = [{ text: "x" }, { text: "y" }];
|
|
216
|
-
const embeddings: ReadonlyArray<ReadonlyArray<number>> = [
|
|
217
|
-
[0, 1],
|
|
218
|
-
[0, 1],
|
|
219
|
-
];
|
|
220
|
-
const embedder: EmbedderFn = async () => [[1, 0]];
|
|
221
|
-
const order = await rankByRelevance(items, embeddings, "q", embedder);
|
|
222
|
-
expect(order).toEqual([0, 1]);
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
test("does not trim when topK is greater than or equal to the item count", async () => {
|
|
226
|
-
const items: ReadonlyArray<Item> = [{ text: "x" }, { text: "y" }];
|
|
227
|
-
const embeddings: ReadonlyArray<ReadonlyArray<number>> = [
|
|
228
|
-
[1, 0],
|
|
229
|
-
[0, 1],
|
|
230
|
-
];
|
|
231
|
-
const embedder: EmbedderFn = async () => [[1, 0]];
|
|
232
|
-
const order = await rankByRelevance(items, embeddings, "q", embedder, 5);
|
|
233
|
-
expect(order).toEqual([0, 1]);
|
|
234
|
-
});
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
describe("cosine (via dedupeBySimilarity / curate)", () => {
|
|
238
|
-
test("throws on embedding dimension mismatch", () => {
|
|
239
|
-
// First kept vector has dim 2, second item has dim 3 -> cosine throws when
|
|
240
|
-
// comparing item 1 against kept item 0.
|
|
241
|
-
const items: ReadonlyArray<Item> = [{ text: "a" }, { text: "b" }];
|
|
242
|
-
const embeddings: ReadonlyArray<ReadonlyArray<number>> = [
|
|
243
|
-
[1, 0],
|
|
244
|
-
[1, 0, 0],
|
|
245
|
-
];
|
|
246
|
-
expect(() => dedupeBySimilarity(items, embeddings, 0.92)).toThrow(CompactionCuratorError);
|
|
247
|
-
// dedupe compares the current item (index 1, dim 3) against the kept item
|
|
248
|
-
// (index 0, dim 2), so the message reports the dims in that order.
|
|
249
|
-
expect(() => dedupeBySimilarity(items, embeddings, 0.92)).toThrow(
|
|
250
|
-
/embedding dimension mismatch \(3 vs 2\)/,
|
|
251
|
-
);
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
test("zero-norm vector yields cosine 0 (not a duplicate, no divide-by-zero)", () => {
|
|
255
|
-
// Item 1 is the zero vector: cosine(any, 0) === 0 < threshold, so it is
|
|
256
|
-
// kept rather than collapsed, and no NaN/Infinity leaks through.
|
|
257
|
-
const items: ReadonlyArray<Item> = [{ text: "a" }, { text: "b" }];
|
|
258
|
-
const embeddings: ReadonlyArray<ReadonlyArray<number>> = [
|
|
259
|
-
[1, 0],
|
|
260
|
-
[0, 0],
|
|
261
|
-
];
|
|
262
|
-
const r = dedupeBySimilarity(items, embeddings, 0.92);
|
|
263
|
-
expect(r.kept).toEqual([0, 1]);
|
|
264
|
-
expect(r.dropped).toEqual([]);
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
test("treats missing trailing components as zero when dims are equal length", () => {
|
|
268
|
-
// Arrays of equal length whose trailing slot is `undefined` -> the `?? 0`
|
|
269
|
-
// fallbacks keep the dot/norm math finite. [1, undefined] vs [1, undefined]
|
|
270
|
-
// is identical -> dedupe.
|
|
271
|
-
const sparse = [1, undefined] as unknown as ReadonlyArray<number>;
|
|
272
|
-
const items: ReadonlyArray<Item> = [{ text: "a" }, { text: "b" }];
|
|
273
|
-
const embeddings: ReadonlyArray<ReadonlyArray<number>> = [sparse, sparse];
|
|
274
|
-
const r = dedupeBySimilarity(items, embeddings, 0.92);
|
|
275
|
-
expect(r.kept).toEqual([0]);
|
|
276
|
-
expect(r.dropped).toEqual([1]);
|
|
277
|
-
});
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
describe("dedupeBySimilarity — missing embeddings are skipped", () => {
|
|
281
|
-
test("a kept item with no embedding is never matched against", () => {
|
|
282
|
-
// Index 0 has no embedding (undefined): the inner loop's `continue` keeps it,
|
|
283
|
-
// and a later identical item is still compared against other kept items.
|
|
284
|
-
const items: ReadonlyArray<Item> = [{ text: "a" }, { text: "b" }, { text: "c" }];
|
|
285
|
-
const embeddings = [undefined, [1, 0], [1, 0]] as unknown as ReadonlyArray<
|
|
286
|
-
ReadonlyArray<number>
|
|
287
|
-
>;
|
|
288
|
-
const r = dedupeBySimilarity(items, embeddings, 0.92);
|
|
289
|
-
// 0 kept (no embedding), 1 kept (first real), 2 dropped (dup of 1)
|
|
290
|
-
expect(r.kept).toEqual([0, 1]);
|
|
291
|
-
expect(r.dropped).toEqual([2]);
|
|
292
|
-
});
|
|
293
|
-
|
|
294
|
-
test("current item with no embedding is kept (inner comparison skipped)", () => {
|
|
295
|
-
const items: ReadonlyArray<Item> = [{ text: "a" }, { text: "b" }];
|
|
296
|
-
const embeddings = [[1, 0], undefined] as unknown as ReadonlyArray<ReadonlyArray<number>>;
|
|
297
|
-
const r = dedupeBySimilarity(items, embeddings, 0.92);
|
|
298
|
-
expect(r.kept).toEqual([0, 1]);
|
|
299
|
-
expect(r.dropped).toEqual([]);
|
|
300
|
-
});
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
describe("curate — embedder failure modes", () => {
|
|
304
|
-
test("throws when the embedder returns fewer vectors than requested", async () => {
|
|
305
|
-
// Two items need embeddings but the embedder returns only one vector ->
|
|
306
|
-
// the second slot is undefined and curate reports the offending index.
|
|
307
|
-
const embedder: EmbedderFn = async () => [[1, 0]];
|
|
308
|
-
const items: ReadonlyArray<Item> = [
|
|
309
|
-
{ text: "first needs embedding" },
|
|
310
|
-
{ text: "second needs embedding" },
|
|
311
|
-
];
|
|
312
|
-
await expect(curate(items, { embedder })).rejects.toThrow(
|
|
313
|
-
/embedder returned no vector for item 1/,
|
|
314
|
-
);
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
test("mixes pre-embedded and freshly embedded items in original order", async () => {
|
|
318
|
-
// Item 0 is pre-embedded, item 1 needs embedding. The fresh vector must land
|
|
319
|
-
// in the right slot so the two are NOT collapsed (different vectors).
|
|
320
|
-
let seen: ReadonlyArray<string> | undefined;
|
|
321
|
-
const embedder: EmbedderFn = async (texts) => {
|
|
322
|
-
seen = texts;
|
|
323
|
-
return texts.map(() => [0, 1]);
|
|
324
|
-
};
|
|
325
|
-
const items: ReadonlyArray<Item> = [
|
|
326
|
-
{ text: "pre embedded item", embedding: [1, 0] },
|
|
327
|
-
{ text: "needs embedding item" },
|
|
328
|
-
];
|
|
329
|
-
const result = await curate(items, { embedder });
|
|
330
|
-
// Only the missing one is sent to the embedder.
|
|
331
|
-
expect(seen).toEqual(["needs embedding item"]);
|
|
332
|
-
expect(result.items.length).toBe(2);
|
|
333
|
-
expect(result.droppedIndices).toEqual([]);
|
|
334
|
-
});
|
|
335
|
-
});
|
|
336
|
-
|
|
337
|
-
describe("curate — query/topK branch coverage", () => {
|
|
338
|
-
test("ignores an empty-string query (dedupe only, no reorder)", async () => {
|
|
339
|
-
// query "" has length 0, so the relevance branch is skipped and order is
|
|
340
|
-
// preserved as-is.
|
|
341
|
-
const items: ReadonlyArray<Item> = [
|
|
342
|
-
{ text: "alpha gamma delta epsilon zeta", embedding: [1, 0] },
|
|
343
|
-
{ text: "beta theta iota kappa lambda", embedding: [0, 1] },
|
|
344
|
-
];
|
|
345
|
-
const result = await curate(items, { query: "" });
|
|
346
|
-
expect(result.items.length).toBe(2);
|
|
347
|
-
expect(result.originalIndices).toEqual([0, 1]);
|
|
348
|
-
expect(result.droppedIndices).toEqual([]);
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
test("query reorder without topK keeps every surviving item (no drops)", async () => {
|
|
352
|
-
// With a query but no relevanceTopK, items are only reordered; the topK
|
|
353
|
-
// drop-accounting branch must NOT run.
|
|
354
|
-
const items: ReadonlyArray<Item> = [
|
|
355
|
-
{ text: "off topic", id: "a", embedding: [0, 1] },
|
|
356
|
-
{ text: "on topic", id: "b", embedding: [1, 0] },
|
|
357
|
-
];
|
|
358
|
-
const embedder: EmbedderFn = async () => [[1, 0]]; // query vector
|
|
359
|
-
const result = await curate(items, { query: "q", embedder });
|
|
360
|
-
expect(result.items.map((i) => i.id)).toEqual(["b", "a"]);
|
|
361
|
-
expect(result.droppedIndices).toEqual([]);
|
|
362
|
-
});
|
|
363
|
-
|
|
364
|
-
test("dedupe drops and topK drops are merged and sorted in droppedIndices", async () => {
|
|
365
|
-
// index 0 & 1 are duplicates (index 1 dedupe-dropped); of the survivors,
|
|
366
|
-
// topK=1 trims one more. Final droppedIndices must be sorted ascending.
|
|
367
|
-
// Distinct, orthogonal vectors per text so only the intended pair collides.
|
|
368
|
-
const vectors: Record<string, ReadonlyArray<number>> = {
|
|
369
|
-
query: [1, 0, 0],
|
|
370
|
-
dup: [0, 1, 0], // items 0 and 1 share this -> dedupe collapse
|
|
371
|
-
match: [1, 0, 0], // identical to the query -> highest relevance
|
|
372
|
-
filler: [0, 0, 1], // distinct from everything -> survives dedupe, topK-trimmed
|
|
373
|
-
};
|
|
374
|
-
const embedder: EmbedderFn = async (texts) => texts.map((t) => vectors[t] ?? [0, 0, 0]);
|
|
375
|
-
const items: ReadonlyArray<Item> = [
|
|
376
|
-
{ text: "dup", id: "0" }, // dedupe-kept, then topK-dropped (sim 0)
|
|
377
|
-
{ text: "dup", id: "1" }, // dedupe-dropped (same vector as 0)
|
|
378
|
-
{ text: "match", id: "2" }, // most relevant -> kept by topK
|
|
379
|
-
{ text: "filler", id: "3" }, // dedupe-kept, then topK-dropped (sim 0)
|
|
380
|
-
];
|
|
381
|
-
const result = await curate(items, {
|
|
382
|
-
embedder,
|
|
383
|
-
query: "query",
|
|
384
|
-
relevanceTopK: 1,
|
|
385
|
-
});
|
|
386
|
-
expect(result.items.map((i) => i.id)).toEqual(["2"]);
|
|
387
|
-
// 1 from dedupe; 0 and 3 from topK trim -> sorted ascending, no duplicates.
|
|
388
|
-
expect(result.droppedIndices).toEqual([0, 1, 3]);
|
|
389
|
-
// bytesSaved counts the dedupe drop plus both topK drops.
|
|
390
|
-
const expectedBytes =
|
|
391
|
-
Buffer.byteLength("dup", "utf8") + // index 1 (dedupe)
|
|
392
|
-
Buffer.byteLength("dup", "utf8") + // index 0 (topK)
|
|
393
|
-
Buffer.byteLength("filler", "utf8"); // index 3 (topK)
|
|
394
|
-
expect(result.bytesSaved).toBe(expectedBytes);
|
|
395
|
-
});
|
|
396
|
-
|
|
397
|
-
test("relevanceTopK without a query is ignored (no reorder, no drops)", async () => {
|
|
398
|
-
// The topK filter only applies inside the query branch; without a query it
|
|
399
|
-
// must have no effect.
|
|
400
|
-
const items: ReadonlyArray<Item> = [
|
|
401
|
-
{ text: "one", embedding: [1, 0] },
|
|
402
|
-
{ text: "two", embedding: [0, 1] },
|
|
403
|
-
{ text: "three", embedding: [1, 1] },
|
|
404
|
-
];
|
|
405
|
-
const result = await curate(items, { relevanceTopK: 1 });
|
|
406
|
-
expect(result.items.length).toBe(3);
|
|
407
|
-
expect(result.droppedIndices).toEqual([]);
|
|
408
|
-
});
|
|
409
|
-
});
|
|
410
|
-
|
|
411
|
-
describe("CompactionCuratorError", () => {
|
|
412
|
-
test("carries the config code and a wrapped cause", () => {
|
|
413
|
-
const cause = new Error("boom");
|
|
414
|
-
const err = new CompactionCuratorError("wrapped", cause);
|
|
415
|
-
expect(err).toBeInstanceOf(Error);
|
|
416
|
-
expect(err.name).toBe("CompactionCuratorError");
|
|
417
|
-
expect(err.code).toBe("config");
|
|
418
|
-
expect(err.cause).toBe(cause);
|
|
419
|
-
expect(err.message).toBe("wrapped");
|
|
420
|
-
});
|
|
421
|
-
});
|
package/src/index.ts
DELETED
|
@@ -1,288 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pillar 2 active context curation — pre-compaction relevance + dedupe.
|
|
3
|
-
*
|
|
4
|
-
* `compaction-autocompact` (model summarize-then-replace) and
|
|
5
|
-
* `compaction-snip` (drop oldest) are *reactive*: they only fire when
|
|
6
|
-
* context is already near its limit, and they cost a model call (autocompact)
|
|
7
|
-
* or accept information loss (snip). Routray's "Stop Wasting Money on AI
|
|
8
|
-
* Context You Don't Need" (Mar 2026) documents 60–80% token reduction
|
|
9
|
-
* from a pre-compaction pass that:
|
|
10
|
-
*
|
|
11
|
-
* 1. Removes semantically duplicate items (embedding cosine ≥ threshold).
|
|
12
|
-
* 2. Re-orders to front-load high-relevance items (since transformer
|
|
13
|
-
* attention favors prompt-start and prompt-end positions).
|
|
14
|
-
*
|
|
15
|
-
* This package implements both as pure functions over a generic `Item`
|
|
16
|
-
* shape (string text + optional embedding). Callers (compaction-autocompact
|
|
17
|
-
* primarily) wire it as an opt-in stage controlled by `spec.compaction.curate`.
|
|
18
|
-
* When curation brings the item count below the model-summarize trigger,
|
|
19
|
-
* the summarization model call is skipped entirely — that's where the
|
|
20
|
-
* cost savings compound on top of token savings.
|
|
21
|
-
*
|
|
22
|
-
* The package does NOT carry an embedder dependency. Callers supply
|
|
23
|
-
* `EmbedderFn` — typically wrapping `@crewhaus/embedder`'s configured
|
|
24
|
-
* provider. This keeps `compaction-curator` light enough to be useful
|
|
25
|
-
* from `target-pipeline` (which already has an embedder) without forcing
|
|
26
|
-
* a heavy import onto `target-cli` (which doesn't).
|
|
27
|
-
*
|
|
28
|
-
* Catalog layer: R6 (extension of §17 compaction primitives, symmetric to
|
|
29
|
-
* `compaction-autocompact` and `compaction-snip`). Recipe:
|
|
30
|
-
* demos/walkthroughs/52-context-curation.md.
|
|
31
|
-
*/
|
|
32
|
-
import { CrewhausError } from "@crewhaus/errors";
|
|
33
|
-
|
|
34
|
-
export class CompactionCuratorError extends CrewhausError {
|
|
35
|
-
override readonly name = "CompactionCuratorError";
|
|
36
|
-
constructor(message: string, cause?: unknown) {
|
|
37
|
-
super("config", message, cause);
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* The generic shape this package operates on. Sufficient for two backends:
|
|
43
|
-
* - context items: `text` = message body, `embedding` = optional pre-
|
|
44
|
-
* computed vector
|
|
45
|
-
* - RAG-retrieved chunks: `text` = chunk body, `embedding` = the vector
|
|
46
|
-
* already used for retrieval
|
|
47
|
-
*/
|
|
48
|
-
export type Item = {
|
|
49
|
-
readonly text: string;
|
|
50
|
-
/** Optional pre-computed embedding. When absent, callers must supply
|
|
51
|
-
* `EmbedderFn` to `curate()`. */
|
|
52
|
-
readonly embedding?: ReadonlyArray<number>;
|
|
53
|
-
/** Optional client-side identifier carried through unchanged. */
|
|
54
|
-
readonly id?: string;
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Async embedder callback. Returns a vector per input string in the same
|
|
59
|
-
* order. Implementations typically batch internally for cost efficiency.
|
|
60
|
-
*
|
|
61
|
-
* Vectors must be normalized (unit length) so cosine reduces to a dot
|
|
62
|
-
* product. Most production embedders return normalized vectors by default;
|
|
63
|
-
* if yours doesn't, normalize before returning here.
|
|
64
|
-
*/
|
|
65
|
-
export type EmbedderFn = (
|
|
66
|
-
texts: ReadonlyArray<string>,
|
|
67
|
-
) => Promise<ReadonlyArray<ReadonlyArray<number>>>;
|
|
68
|
-
|
|
69
|
-
export type CurateOptions = {
|
|
70
|
-
/**
|
|
71
|
-
* Query / goal string used to score relevance. When absent, the curator
|
|
72
|
-
* skips the relevance-reorder pass and only does dedupe.
|
|
73
|
-
*/
|
|
74
|
-
readonly query?: string;
|
|
75
|
-
/**
|
|
76
|
-
* Cosine-similarity threshold above which two items are considered
|
|
77
|
-
* duplicates. Default 0.92 — high enough to avoid false-positive
|
|
78
|
-
* collapses on near-but-distinct items (a function definition and its
|
|
79
|
-
* caller share substantial token overlap but should NOT dedupe).
|
|
80
|
-
*/
|
|
81
|
-
readonly dedupeThreshold?: number;
|
|
82
|
-
/**
|
|
83
|
-
* Max items to keep after relevance scoring. Items below this rank are
|
|
84
|
-
* dropped entirely. When absent, no items are dropped from the
|
|
85
|
-
* relevance pass — items are only reordered. Set when you want a hard
|
|
86
|
-
* top-K filter.
|
|
87
|
-
*/
|
|
88
|
-
readonly relevanceTopK?: number;
|
|
89
|
-
/**
|
|
90
|
-
* Embedder supplied by the caller. Required if any item lacks a
|
|
91
|
-
* pre-computed `embedding` AND either dedupe or relevance reorder is
|
|
92
|
-
* requested. The function-level types let it stay optional for the
|
|
93
|
-
* pre-embedded case (most production callers).
|
|
94
|
-
*/
|
|
95
|
-
readonly embedder?: EmbedderFn;
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
export const DEFAULT_DEDUPE_THRESHOLD = 0.92;
|
|
99
|
-
|
|
100
|
-
function cosine(a: ReadonlyArray<number>, b: ReadonlyArray<number>): number {
|
|
101
|
-
if (a.length !== b.length) {
|
|
102
|
-
throw new CompactionCuratorError(`embedding dimension mismatch (${a.length} vs ${b.length})`);
|
|
103
|
-
}
|
|
104
|
-
let dot = 0;
|
|
105
|
-
let na = 0;
|
|
106
|
-
let nb = 0;
|
|
107
|
-
for (let i = 0; i < a.length; i++) {
|
|
108
|
-
const av = a[i] ?? 0;
|
|
109
|
-
const bv = b[i] ?? 0;
|
|
110
|
-
dot += av * bv;
|
|
111
|
-
na += av * av;
|
|
112
|
-
nb += bv * bv;
|
|
113
|
-
}
|
|
114
|
-
if (na === 0 || nb === 0) return 0;
|
|
115
|
-
return dot / Math.sqrt(na * nb);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Ensure every input item has an embedding. Items already carrying one
|
|
120
|
-
* pass through; items missing one are batched into a single embedder
|
|
121
|
-
* call. Preserves order so the caller's downstream indexing isn't
|
|
122
|
-
* disturbed.
|
|
123
|
-
*/
|
|
124
|
-
async function ensureEmbeddings(
|
|
125
|
-
items: ReadonlyArray<Item>,
|
|
126
|
-
embedder: EmbedderFn | undefined,
|
|
127
|
-
): Promise<ReadonlyArray<ReadonlyArray<number>>> {
|
|
128
|
-
if (items.length === 0) return [];
|
|
129
|
-
const missingIdx: number[] = [];
|
|
130
|
-
const missingTexts: string[] = [];
|
|
131
|
-
for (let i = 0; i < items.length; i++) {
|
|
132
|
-
const item = items[i];
|
|
133
|
-
if (item !== undefined && item.embedding === undefined) {
|
|
134
|
-
missingIdx.push(i);
|
|
135
|
-
missingTexts.push(item.text);
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
if (missingIdx.length > 0 && embedder === undefined) {
|
|
139
|
-
throw new CompactionCuratorError(
|
|
140
|
-
`${missingIdx.length} item(s) lack pre-computed embeddings and no embedder was supplied — pass options.embedder or pre-embed your items`,
|
|
141
|
-
);
|
|
142
|
-
}
|
|
143
|
-
const fresh = embedder !== undefined && missingIdx.length > 0 ? await embedder(missingTexts) : [];
|
|
144
|
-
return items.map((item, i) => {
|
|
145
|
-
if (item.embedding !== undefined) return item.embedding;
|
|
146
|
-
const slot = missingIdx.indexOf(i);
|
|
147
|
-
if (slot === -1) {
|
|
148
|
-
throw new CompactionCuratorError(`internal: missing embedding for item ${i}`);
|
|
149
|
-
}
|
|
150
|
-
const v = fresh[slot];
|
|
151
|
-
if (v === undefined) {
|
|
152
|
-
throw new CompactionCuratorError(`embedder returned no vector for item ${i}`);
|
|
153
|
-
}
|
|
154
|
-
return v;
|
|
155
|
-
});
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Pass 1 — semantic dedupe. For each item, if a *prior* item's embedding
|
|
160
|
-
* has cosine ≥ threshold, drop the current. Order-preserving: the first
|
|
161
|
-
* occurrence wins (keeps the head of the conversation, which carries
|
|
162
|
-
* goal-setting context).
|
|
163
|
-
*/
|
|
164
|
-
export function dedupeBySimilarity(
|
|
165
|
-
items: ReadonlyArray<Item>,
|
|
166
|
-
embeddings: ReadonlyArray<ReadonlyArray<number>>,
|
|
167
|
-
threshold: number,
|
|
168
|
-
): { kept: ReadonlyArray<number>; dropped: ReadonlyArray<number> } {
|
|
169
|
-
const kept: number[] = [];
|
|
170
|
-
const dropped: number[] = [];
|
|
171
|
-
for (let i = 0; i < items.length; i++) {
|
|
172
|
-
let isDuplicate = false;
|
|
173
|
-
for (const k of kept) {
|
|
174
|
-
const e1 = embeddings[i];
|
|
175
|
-
const e2 = embeddings[k];
|
|
176
|
-
if (e1 === undefined || e2 === undefined) continue;
|
|
177
|
-
if (cosine(e1, e2) >= threshold) {
|
|
178
|
-
isDuplicate = true;
|
|
179
|
-
break;
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
if (isDuplicate) dropped.push(i);
|
|
183
|
-
else kept.push(i);
|
|
184
|
-
}
|
|
185
|
-
return { kept, dropped };
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* Pass 2 — relevance reorder. Compute cosine(query, item) for every
|
|
190
|
-
* surviving item; return indices in descending similarity order.
|
|
191
|
-
* Stable sort: items with identical similarity stay in original order.
|
|
192
|
-
*
|
|
193
|
-
* When `topK` is supplied, items beyond that rank are dropped.
|
|
194
|
-
*/
|
|
195
|
-
export async function rankByRelevance(
|
|
196
|
-
items: ReadonlyArray<Item>,
|
|
197
|
-
itemEmbeddings: ReadonlyArray<ReadonlyArray<number>>,
|
|
198
|
-
query: string,
|
|
199
|
-
embedder: EmbedderFn | undefined,
|
|
200
|
-
topK?: number,
|
|
201
|
-
): Promise<ReadonlyArray<number>> {
|
|
202
|
-
if (items.length === 0) return [];
|
|
203
|
-
if (embedder === undefined) {
|
|
204
|
-
throw new CompactionCuratorError(
|
|
205
|
-
"rankByRelevance requires an embedder to compute the query vector",
|
|
206
|
-
);
|
|
207
|
-
}
|
|
208
|
-
const queryVecs = await embedder([query]);
|
|
209
|
-
const queryVec = queryVecs[0];
|
|
210
|
-
if (queryVec === undefined) {
|
|
211
|
-
throw new CompactionCuratorError("embedder returned no vector for the query");
|
|
212
|
-
}
|
|
213
|
-
const scored: Array<{ idx: number; sim: number; original: number }> = [];
|
|
214
|
-
for (let i = 0; i < items.length; i++) {
|
|
215
|
-
const e = itemEmbeddings[i];
|
|
216
|
-
if (e === undefined) continue;
|
|
217
|
-
scored.push({ idx: i, sim: cosine(queryVec, e), original: i });
|
|
218
|
-
}
|
|
219
|
-
scored.sort((a, b) => {
|
|
220
|
-
if (a.sim !== b.sim) return b.sim - a.sim;
|
|
221
|
-
return a.original - b.original; // stable
|
|
222
|
-
});
|
|
223
|
-
const ordered = scored.map((s) => s.idx);
|
|
224
|
-
if (topK !== undefined && ordered.length > topK) return ordered.slice(0, topK);
|
|
225
|
-
return ordered;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
export type CurationResult = {
|
|
229
|
-
/** Items in the curated order (deduped + relevance-ranked + topK-trimmed). */
|
|
230
|
-
readonly items: ReadonlyArray<Item>;
|
|
231
|
-
/** Indices into the *original* input array, in output order. */
|
|
232
|
-
readonly originalIndices: ReadonlyArray<number>;
|
|
233
|
-
/** Indices that were dropped (in original order). */
|
|
234
|
-
readonly droppedIndices: ReadonlyArray<number>;
|
|
235
|
-
/** Bytes saved as compared to the original total (text only). */
|
|
236
|
-
readonly bytesSaved: number;
|
|
237
|
-
};
|
|
238
|
-
|
|
239
|
-
/**
|
|
240
|
-
* High-level entry: dedupe then relevance-reorder (when a query is
|
|
241
|
-
* supplied) then top-K filter. The most common call from
|
|
242
|
-
* `compaction-autocompact` is `curate(items, { query, embedder })`.
|
|
243
|
-
*/
|
|
244
|
-
export async function curate(
|
|
245
|
-
items: ReadonlyArray<Item>,
|
|
246
|
-
opts: CurateOptions = {},
|
|
247
|
-
): Promise<CurationResult> {
|
|
248
|
-
if (items.length === 0) {
|
|
249
|
-
return { items: [], originalIndices: [], droppedIndices: [], bytesSaved: 0 };
|
|
250
|
-
}
|
|
251
|
-
const threshold = opts.dedupeThreshold ?? DEFAULT_DEDUPE_THRESHOLD;
|
|
252
|
-
const embeddings = await ensureEmbeddings(items, opts.embedder);
|
|
253
|
-
const { kept, dropped } = dedupeBySimilarity(items, embeddings, threshold);
|
|
254
|
-
let surviving: ReadonlyArray<number> = kept;
|
|
255
|
-
const droppedAll: number[] = [...dropped];
|
|
256
|
-
if (opts.query !== undefined && opts.query.length > 0) {
|
|
257
|
-
const subItems = surviving.map((i) => items[i]).filter((it): it is Item => it !== undefined);
|
|
258
|
-
const subEmbeddings = surviving
|
|
259
|
-
.map((i) => embeddings[i])
|
|
260
|
-
.filter((e): e is ReadonlyArray<number> => e !== undefined);
|
|
261
|
-
const localOrder = await rankByRelevance(
|
|
262
|
-
subItems,
|
|
263
|
-
subEmbeddings,
|
|
264
|
-
opts.query,
|
|
265
|
-
opts.embedder,
|
|
266
|
-
opts.relevanceTopK,
|
|
267
|
-
);
|
|
268
|
-
const localToOriginal: ReadonlyArray<number> = surviving;
|
|
269
|
-
surviving = localOrder
|
|
270
|
-
.map((localIdx) => localToOriginal[localIdx])
|
|
271
|
-
.filter((i): i is number => i !== undefined);
|
|
272
|
-
if (opts.relevanceTopK !== undefined) {
|
|
273
|
-
const survivingSet = new Set(surviving);
|
|
274
|
-
for (const i of kept) if (!survivingSet.has(i)) droppedAll.push(i);
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
const outItems = surviving.map((i) => items[i]).filter((it): it is Item => it !== undefined);
|
|
278
|
-
const droppedTotal = droppedAll
|
|
279
|
-
.map((i) => items[i])
|
|
280
|
-
.filter((it): it is Item => it !== undefined)
|
|
281
|
-
.reduce((acc, it) => acc + Buffer.byteLength(it.text, "utf8"), 0);
|
|
282
|
-
return {
|
|
283
|
-
items: outItems,
|
|
284
|
-
originalIndices: surviving,
|
|
285
|
-
droppedIndices: [...droppedAll].sort((a, b) => a - b),
|
|
286
|
-
bytesSaved: droppedTotal,
|
|
287
|
-
};
|
|
288
|
-
}
|