test(sf): lock embedding lifecycle hygiene contract
Three new tests covering the embedding-cleanup paths shipped in7bec2dc2d/1b71ddd17/05a326a29: 1. updateMemoryContent → drops the existing memory_embeddings row (next backfill re-embeds the new content). 2. supersedeMemory → drops the superseded memory's embedding while preserving the live one's. 3. enforceMemoryCap → sweeps embeddings of newly-superseded memories so memory_embeddings stays aligned with active memories after a batch cap. Without these, a regression in the cleanup paths would silently leave orphaned vectors that loadAllEmbeddings's superseded_by filter masks at query time but bloats the table forever. 11 → 14 tests in memory-store.test.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3b5e6588e9
commit
54f27bd02c
1 changed files with 110 additions and 0 deletions
|
|
@ -519,3 +519,113 @@ test("memory-store: schema includes memories table", () => {
|
|||
|
||||
closeDatabase();
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// memory-store: embedding lifecycle hygiene
|
||||
// (locks the contract: updateMemoryContent invalidates, supersedeMemory drops,
|
||||
// enforceMemoryCap sweeps. Without these, memory_embeddings accumulates dead
|
||||
// rows and ranking returns stale vectors.)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test("updateMemoryContent invalidates the embedding row", async () => {
|
||||
closeDatabase();
|
||||
const { mkdtempSync, rmSync } = await import("node:fs");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
const { saveEmbedding } = await import("../memory-embeddings.ts");
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), "sf-update-embed-"));
|
||||
openDatabase(join(dir, "sf.db"));
|
||||
const id = createMemory({ category: "architecture", content: "old" });
|
||||
assert.ok(id);
|
||||
saveEmbedding(id, Float32Array.from([1, 2, 3]), "test-model");
|
||||
const adapter = _getAdapter()!;
|
||||
const before = adapter
|
||||
.prepare("SELECT count(*) as c FROM memory_embeddings WHERE memory_id = :id")
|
||||
.get({ ":id": id });
|
||||
assert.equal(before?.["c"], 1);
|
||||
|
||||
updateMemoryContent(id, "new content");
|
||||
|
||||
const after = adapter
|
||||
.prepare("SELECT count(*) as c FROM memory_embeddings WHERE memory_id = :id")
|
||||
.get({ ":id": id });
|
||||
assert.equal(after?.["c"], 0, "embedding row should be dropped after content update");
|
||||
|
||||
closeDatabase();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("supersedeMemory drops the superseded memory's embedding row", async () => {
|
||||
closeDatabase();
|
||||
const { mkdtempSync, rmSync } = await import("node:fs");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
const { saveEmbedding } = await import("../memory-embeddings.ts");
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), "sf-supersede-embed-"));
|
||||
openDatabase(join(dir, "sf.db"));
|
||||
const oldId = createMemory({ category: "architecture", content: "old" });
|
||||
const newId = createMemory({ category: "architecture", content: "new" });
|
||||
assert.ok(oldId && newId);
|
||||
saveEmbedding(oldId, Float32Array.from([1, 2, 3]), "test-model");
|
||||
saveEmbedding(newId, Float32Array.from([4, 5, 6]), "test-model");
|
||||
|
||||
supersedeMemory(oldId, newId);
|
||||
|
||||
const adapter = _getAdapter()!;
|
||||
const oldRow = adapter
|
||||
.prepare("SELECT count(*) as c FROM memory_embeddings WHERE memory_id = :id")
|
||||
.get({ ":id": oldId });
|
||||
const newRow = adapter
|
||||
.prepare("SELECT count(*) as c FROM memory_embeddings WHERE memory_id = :id")
|
||||
.get({ ":id": newId });
|
||||
assert.equal(oldRow?.["c"], 0, "superseded memory's embedding should be dropped");
|
||||
assert.equal(newRow?.["c"], 1, "live memory's embedding should be preserved");
|
||||
|
||||
closeDatabase();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("enforceMemoryCap sweeps embeddings of newly-superseded memories", async () => {
|
||||
closeDatabase();
|
||||
const { mkdtempSync, rmSync } = await import("node:fs");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
const { saveEmbedding } = await import("../memory-embeddings.ts");
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), "sf-cap-embed-"));
|
||||
openDatabase(join(dir, "sf.db"));
|
||||
// Create 5 memories with descending confidence so the cap predictably
|
||||
// keeps the top 3.
|
||||
const ids: string[] = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const id = createMemory({
|
||||
category: "architecture",
|
||||
content: `memory ${i}`,
|
||||
confidence: 0.9 - i * 0.1,
|
||||
});
|
||||
assert.ok(id);
|
||||
ids.push(id);
|
||||
saveEmbedding(id, Float32Array.from([i, i, i]), "test-model");
|
||||
}
|
||||
const adapter = _getAdapter()!;
|
||||
const beforeCount = adapter
|
||||
.prepare("SELECT count(*) as c FROM memory_embeddings")
|
||||
.get();
|
||||
assert.equal(beforeCount?.["c"], 5);
|
||||
|
||||
enforceMemoryCap(3);
|
||||
|
||||
const afterCount = adapter
|
||||
.prepare("SELECT count(*) as c FROM memory_embeddings")
|
||||
.get();
|
||||
assert.equal(
|
||||
afterCount?.["c"],
|
||||
3,
|
||||
"embeddings should align with the 3 active memories after cap enforcement",
|
||||
);
|
||||
|
||||
closeDatabase();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue