171 lines
6.5 KiB
TypeScript
171 lines
6.5 KiB
TypeScript
import { describe, it, beforeEach } from "node:test";
|
||
import assert from "node:assert/strict";
|
||
import {
|
||
tokenizeWordPiece,
|
||
meanPool,
|
||
_injectModel,
|
||
type PotionModel,
|
||
} from "../../src/lib/memory/embedding/staticPotion";
|
||
import type { EmbeddingError } from "../../src/lib/memory/embedding/types";
|
||
import { invalidate as invalidateCache } from "../../src/lib/memory/embedding/cache";
|
||
|
||
// ---- Mock model setup ----
|
||
// Vocab: {"[UNK]":0, "hello":1, "world":2}
|
||
// Matrix: 3 rows × 4 dims
|
||
// Row 0 ([UNK]): [0.0, 0.0, 0.0, 0.0]
|
||
// Row 1 (hello): [1.0, 0.0, 0.0, 0.0]
|
||
// Row 2 (world): [0.0, 1.0, 0.0, 0.0]
|
||
|
||
function makeMockModel(): PotionModel {
|
||
const vocab: Record<string, number> = { "[UNK]": 0, "hello": 1, "world": 2 };
|
||
const matrix = new Float32Array([
|
||
0.0, 0.0, 0.0, 0.0, // row 0 = [UNK]
|
||
1.0, 0.0, 0.0, 0.0, // row 1 = hello
|
||
0.0, 1.0, 0.0, 0.0, // row 2 = world
|
||
]);
|
||
return { vocab, matrix, dim: 4, vocabSize: 3, unkIdx: 0 };
|
||
}
|
||
|
||
describe("memory-embedding-static-potion tokenizer", () => {
|
||
const mock = makeMockModel();
|
||
|
||
it("tokenizes known words to their vocab IDs", () => {
|
||
const ids = tokenizeWordPiece("hello world", mock.vocab);
|
||
assert.deepStrictEqual(ids, [1, 2]);
|
||
});
|
||
|
||
it("unknown words fall back to [UNK] (id=0)", () => {
|
||
const ids = tokenizeWordPiece("foo bar", mock.vocab);
|
||
assert.deepStrictEqual(ids, [0, 0]);
|
||
});
|
||
|
||
it("mixed known and unknown tokens", () => {
|
||
const ids = tokenizeWordPiece("hello foo world", mock.vocab);
|
||
assert.deepStrictEqual(ids, [1, 0, 2]);
|
||
});
|
||
|
||
it("empty string returns no tokens", () => {
|
||
const ids = tokenizeWordPiece("", mock.vocab);
|
||
assert.deepStrictEqual(ids, []);
|
||
});
|
||
|
||
it("case-insensitive tokenization", () => {
|
||
// tokenizeWordPiece lowercases input
|
||
const ids = tokenizeWordPiece("Hello World", mock.vocab);
|
||
assert.deepStrictEqual(ids, [1, 2]);
|
||
});
|
||
});
|
||
|
||
describe("memory-embedding-static-potion mean pooling", () => {
|
||
const mock = makeMockModel();
|
||
|
||
it("mean pools hello + world to [0.5, 0.5, 0, 0]", () => {
|
||
const ids = [1, 2]; // hello, world
|
||
const result = meanPool(ids, mock.matrix, mock.dim, mock.vocabSize, mock.unkIdx);
|
||
assert.ok(result instanceof Float32Array);
|
||
assert.strictEqual(result.length, 4);
|
||
assert.ok(Math.abs(result[0] - 0.5) < 0.001, `dim0 should be 0.5, got ${result[0]}`);
|
||
assert.ok(Math.abs(result[1] - 0.5) < 0.001, `dim1 should be 0.5, got ${result[1]}`);
|
||
assert.ok(Math.abs(result[2] - 0.0) < 0.001, `dim2 should be 0, got ${result[2]}`);
|
||
});
|
||
|
||
it("pooling [UNK] returns zero vector", () => {
|
||
const ids = [0]; // [UNK]
|
||
const result = meanPool(ids, mock.matrix, mock.dim, mock.vocabSize, mock.unkIdx);
|
||
for (const v of result) {
|
||
assert.ok(Math.abs(v) < 0.001, `All dims should be 0, got ${v}`);
|
||
}
|
||
});
|
||
|
||
it("empty token list returns zero vector", () => {
|
||
const result = meanPool([], mock.matrix, mock.dim, mock.vocabSize, mock.unkIdx);
|
||
for (const v of result) {
|
||
assert.ok(Math.abs(v) < 0.001, `All dims should be 0, got ${v}`);
|
||
}
|
||
});
|
||
|
||
it("out-of-range token ID falls back to unkIdx", () => {
|
||
const ids = [999]; // out of range
|
||
const result = meanPool(ids, mock.matrix, mock.dim, mock.vocabSize, mock.unkIdx);
|
||
// Should use row 0 ([UNK]) = all zeros
|
||
for (const v of result) {
|
||
assert.ok(Math.abs(v) < 0.001, `All dims should be 0 (unk), got ${v}`);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("memory-embedding-static-potion embedStatic with mock", () => {
|
||
beforeEach(() => {
|
||
invalidateCache();
|
||
_injectModel(makeMockModel());
|
||
});
|
||
|
||
it("embedStatic returns EmbeddingResult for 'hello world'", async () => {
|
||
const { embedStatic } = await import("../../src/lib/memory/embedding/staticPotion");
|
||
const result = await embedStatic("hello world");
|
||
assert.ok("vector" in result, "Should return EmbeddingResult");
|
||
assert.ok((result as { vector: Float32Array }).vector instanceof Float32Array);
|
||
assert.strictEqual((result as { dimensions: number }).dimensions, 4);
|
||
assert.strictEqual((result as { source: string }).source, "static");
|
||
});
|
||
|
||
it("embedStatic uses [UNK] for 'foo' (not in mock vocab)", async () => {
|
||
const { embedStatic } = await import("../../src/lib/memory/embedding/staticPotion");
|
||
const result = await embedStatic("foo");
|
||
assert.ok("vector" in result);
|
||
const vec = (result as { vector: Float32Array }).vector;
|
||
// foo -> [UNK] -> row 0 = [0, 0, 0, 0]
|
||
for (const v of vec) {
|
||
assert.ok(Math.abs(v) < 0.001, `Should be 0 for UNK, got ${v}`);
|
||
}
|
||
});
|
||
|
||
it("model load failure returns EmbeddingError with reason model_load_failed", async () => {
|
||
// Plan 21 fix: previously this test was tautological (`assert.ok(true)`).
|
||
// Force a real load-failure path: point the cache dir at /dev/null/<subdir>
|
||
// so fs.mkdir() fails with ENOTDIR (/dev/null is a file, not a dir).
|
||
// embedStatic catches the error and must return EmbeddingError with
|
||
// reason="model_load_failed" (staticPotion.ts:225-232).
|
||
_injectModel(null);
|
||
const prevCacheDir = process.env.MEMORY_STATIC_CACHE_DIR;
|
||
process.env.MEMORY_STATIC_CACHE_DIR = `/dev/null/potion-load-fail-${process.pid}-${Date.now()}`;
|
||
try {
|
||
const { embedStatic } = await import(
|
||
"../../src/lib/memory/embedding/staticPotion"
|
||
);
|
||
const result = await embedStatic("hello world");
|
||
assert.ok(
|
||
!("vector" in result),
|
||
`Expected EmbeddingError but got result with vector: ${JSON.stringify(result)}`
|
||
);
|
||
const err = result as EmbeddingError;
|
||
assert.strictEqual(err.source, "static");
|
||
assert.strictEqual(err.reason, "model_load_failed");
|
||
assert.ok(
|
||
typeof err.message === "string" && err.message.length > 0,
|
||
"EmbeddingError.message must be a non-empty sanitized string"
|
||
);
|
||
} finally {
|
||
if (prevCacheDir === undefined) {
|
||
delete process.env.MEMORY_STATIC_CACHE_DIR;
|
||
} else {
|
||
process.env.MEMORY_STATIC_CACHE_DIR = prevCacheDir;
|
||
}
|
||
_injectModel(makeMockModel()); // restore for other tests
|
||
}
|
||
});
|
||
|
||
it("second call reuses singleton model (no re-load)", async () => {
|
||
const { embedStatic } = await import("../../src/lib/memory/embedding/staticPotion");
|
||
// First call
|
||
const r1 = await embedStatic("hello");
|
||
// Second call — should reuse singleton
|
||
const r2 = await embedStatic("hello");
|
||
assert.ok("vector" in r1);
|
||
assert.ok("vector" in r2);
|
||
// Both succeed with same model
|
||
assert.strictEqual((r1 as { source: string }).source, "static");
|
||
assert.strictEqual((r2 as { source: string }).source, "static");
|
||
});
|
||
});
|