@crewhaus/compaction-curator 0.1.0 → 0.1.2
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/package.json +6 -11
- package/src/index.test.ts +254 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/compaction-curator",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
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
6
|
"main": "src/index.ts",
|
|
@@ -12,13 +12,13 @@
|
|
|
12
12
|
"test": "bun test src"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@crewhaus/errors": "0.
|
|
15
|
+
"@crewhaus/errors": "0.1.2"
|
|
16
16
|
},
|
|
17
17
|
"license": "Apache-2.0",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "Max Meier",
|
|
20
|
-
"email": "max@
|
|
21
|
-
"url": "https://
|
|
20
|
+
"email": "max@crewhaus.ai",
|
|
21
|
+
"url": "https://crewhaus.ai"
|
|
22
22
|
},
|
|
23
23
|
"repository": {
|
|
24
24
|
"type": "git",
|
|
@@ -30,12 +30,7 @@
|
|
|
30
30
|
"url": "https://github.com/crewhaus/factory/issues"
|
|
31
31
|
},
|
|
32
32
|
"publishConfig": {
|
|
33
|
-
"access": "
|
|
33
|
+
"access": "public"
|
|
34
34
|
},
|
|
35
|
-
"files": [
|
|
36
|
-
"src",
|
|
37
|
-
"README.md",
|
|
38
|
-
"LICENSE",
|
|
39
|
-
"NOTICE"
|
|
40
|
-
]
|
|
35
|
+
"files": ["src", "README.md", "LICENSE", "NOTICE"]
|
|
41
36
|
}
|
package/src/index.test.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
CompactionCuratorError,
|
|
4
|
+
type EmbedderFn,
|
|
5
|
+
type Item,
|
|
6
|
+
curate,
|
|
7
|
+
dedupeBySimilarity,
|
|
8
|
+
rankByRelevance,
|
|
9
|
+
} from "./index";
|
|
3
10
|
|
|
4
11
|
/**
|
|
5
12
|
* Tiny canned embedder for deterministic tests: maps each unique input
|
|
@@ -165,4 +172,250 @@ describe("rankByRelevance (pure)", () => {
|
|
|
165
172
|
const order = await rankByRelevance(items, embeddings, "q", embedder);
|
|
166
173
|
expect(order).toEqual([1, 0]);
|
|
167
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
|
+
});
|
|
168
421
|
});
|