llm.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  1. /**
  2. * llm.test.ts - Unit tests for the LLM abstraction layer (node-llama-cpp)
  3. *
  4. * Run with: bun test src/llm.test.ts
  5. *
  6. * These tests require the actual models to be downloaded. Run the embed or
  7. * rerank functions first to trigger model downloads.
  8. */
  9. import { describe, test, expect, beforeAll, afterAll } from "bun:test";
  10. import {
  11. LlamaCpp,
  12. getDefaultLlamaCpp,
  13. disposeDefaultLlamaCpp,
  14. type RerankDocument,
  15. } from "./llm.js";
  16. // =============================================================================
  17. // Singleton Tests (no model loading required)
  18. // =============================================================================
  19. describe("Default LlamaCpp Singleton", () => {
  20. // Test singleton behavior without resetting to avoid orphan instances
  21. test("getDefaultLlamaCpp returns same instance on subsequent calls", () => {
  22. const llm1 = getDefaultLlamaCpp();
  23. const llm2 = getDefaultLlamaCpp();
  24. expect(llm1).toBe(llm2);
  25. expect(llm1).toBeInstanceOf(LlamaCpp);
  26. });
  27. });
  28. // =============================================================================
  29. // Model Existence Tests
  30. // =============================================================================
  31. describe("LlamaCpp.modelExists", () => {
  32. test("returns exists:true for HuggingFace model URIs", async () => {
  33. const llm = getDefaultLlamaCpp();
  34. const result = await llm.modelExists("hf:org/repo/model.gguf");
  35. expect(result.exists).toBe(true);
  36. expect(result.name).toBe("hf:org/repo/model.gguf");
  37. });
  38. test("returns exists:false for non-existent local paths", async () => {
  39. const llm = getDefaultLlamaCpp();
  40. const result = await llm.modelExists("/nonexistent/path/model.gguf");
  41. expect(result.exists).toBe(false);
  42. expect(result.name).toBe("/nonexistent/path/model.gguf");
  43. });
  44. });
  45. // =============================================================================
  46. // Integration Tests (require actual models)
  47. // =============================================================================
  48. describe("LlamaCpp Integration", () => {
  49. // Use the singleton to avoid multiple Metal contexts
  50. const llm = getDefaultLlamaCpp();
  51. afterAll(async () => {
  52. // Ensure native resources are released to avoid ggml-metal asserts on process exit.
  53. await disposeDefaultLlamaCpp();
  54. });
  55. describe("embed", () => {
  56. test("returns embedding with correct dimensions", async () => {
  57. const result = await llm.embed("Hello world");
  58. expect(result).not.toBeNull();
  59. expect(result!.embedding).toBeInstanceOf(Array);
  60. expect(result!.embedding.length).toBeGreaterThan(0);
  61. // embeddinggemma outputs 768 dimensions
  62. expect(result!.embedding.length).toBe(768);
  63. });
  64. test("returns consistent embeddings for same input", async () => {
  65. const result1 = await llm.embed("test text");
  66. const result2 = await llm.embed("test text");
  67. expect(result1).not.toBeNull();
  68. expect(result2).not.toBeNull();
  69. // Embeddings should be identical for the same input
  70. for (let i = 0; i < result1!.embedding.length; i++) {
  71. expect(result1!.embedding[i]).toBeCloseTo(result2!.embedding[i]!, 5);
  72. }
  73. });
  74. test("returns different embeddings for different inputs", async () => {
  75. const result1 = await llm.embed("cats are great");
  76. const result2 = await llm.embed("database optimization");
  77. expect(result1).not.toBeNull();
  78. expect(result2).not.toBeNull();
  79. // Calculate cosine similarity - should be less than 1.0 (not identical)
  80. let dotProduct = 0;
  81. let norm1 = 0;
  82. let norm2 = 0;
  83. for (let i = 0; i < result1!.embedding.length; i++) {
  84. const v1 = result1!.embedding[i]!;
  85. const v2 = result2!.embedding[i]!;
  86. dotProduct += v1 * v2;
  87. norm1 += v1 ** 2;
  88. norm2 += v2 ** 2;
  89. }
  90. const similarity = dotProduct / (Math.sqrt(norm1) * Math.sqrt(norm2));
  91. expect(similarity).toBeLessThan(0.95); // Should be meaningfully different
  92. });
  93. });
  94. describe("embedBatch", () => {
  95. test("returns embeddings for multiple texts", async () => {
  96. const texts = ["Hello world", "Test text", "Another document"];
  97. const results = await llm.embedBatch(texts);
  98. expect(results).toHaveLength(3);
  99. for (const result of results) {
  100. expect(result).not.toBeNull();
  101. expect(result!.embedding.length).toBe(768);
  102. }
  103. });
  104. test("returns same results as individual embed calls", async () => {
  105. const texts = ["cats are great", "dogs are awesome"];
  106. // Get batch embeddings
  107. const batchResults = await llm.embedBatch(texts);
  108. // Get individual embeddings
  109. const individualResults = await Promise.all(texts.map(t => llm.embed(t)));
  110. // Compare - should be identical
  111. for (let i = 0; i < texts.length; i++) {
  112. expect(batchResults[i]).not.toBeNull();
  113. expect(individualResults[i]).not.toBeNull();
  114. for (let j = 0; j < batchResults[i]!.embedding.length; j++) {
  115. expect(batchResults[i]!.embedding[j]).toBeCloseTo(individualResults[i]!.embedding[j]!, 5);
  116. }
  117. }
  118. });
  119. test("handles empty array", async () => {
  120. const results = await llm.embedBatch([]);
  121. expect(results).toHaveLength(0);
  122. });
  123. test("batch is faster than sequential", async () => {
  124. const texts = Array(10).fill(null).map((_, i) => `Document number ${i} with content`);
  125. // Time batch
  126. const batchStart = Date.now();
  127. await llm.embedBatch(texts);
  128. const batchTime = Date.now() - batchStart;
  129. // Time sequential
  130. const seqStart = Date.now();
  131. for (const text of texts) {
  132. await llm.embed(text);
  133. }
  134. const seqTime = Date.now() - seqStart;
  135. console.log(`Batch: ${batchTime}ms, Sequential: ${seqTime}ms`);
  136. // Performance is machine/load dependent. We only assert batch isn't drastically worse.
  137. expect(batchTime).toBeLessThanOrEqual(seqTime * 3);
  138. });
  139. });
  140. describe("rerank", () => {
  141. test("scores capital of France question correctly", async () => {
  142. const query = "What is the capital of France?";
  143. const documents: RerankDocument[] = [
  144. { file: "butterflies.txt", text: "Butterflies indeed fly through the garden." },
  145. { file: "france.txt", text: "The capital of France is Paris." },
  146. { file: "canada.txt", text: "The capital of Canada is Ottawa." },
  147. ];
  148. const result = await llm.rerank(query, documents);
  149. expect(result.results).toHaveLength(3);
  150. // The France document should score highest
  151. expect(result.results[0]!.file).toBe("france.txt");
  152. expect(result.results[0]!.score).toBeGreaterThan(0.7);
  153. // Canada should be somewhat relevant (also about capitals)
  154. expect(result.results[1]!.file).toBe("canada.txt");
  155. // Butterflies should score lowest
  156. expect(result.results[2]!.file).toBe("butterflies.txt");
  157. expect(result.results[2]!.score).toBeLessThan(0.6);
  158. });
  159. test("scores authentication query correctly", async () => {
  160. const query = "How do I configure authentication?";
  161. const documents: RerankDocument[] = [
  162. { file: "weather.md", text: "The weather today is sunny with mild temperatures." },
  163. { file: "auth.md", text: "Authentication can be configured by setting the AUTH_SECRET environment variable." },
  164. { file: "pizza.md", text: "Our restaurant serves the best pizza in town." },
  165. { file: "jwt.md", text: "JWT authentication requires a secret key and expiration time." },
  166. ];
  167. const result = await llm.rerank(query, documents);
  168. expect(result.results).toHaveLength(4);
  169. // Auth documents should score highest
  170. const topTwo = result.results.slice(0, 2).map((r) => r.file);
  171. expect(topTwo).toContain("auth.md");
  172. expect(topTwo).toContain("jwt.md");
  173. // Irrelevant documents should score lowest
  174. const bottomTwo = result.results.slice(2).map((r) => r.file);
  175. expect(bottomTwo).toContain("weather.md");
  176. expect(bottomTwo).toContain("pizza.md");
  177. });
  178. test("handles programming queries correctly", async () => {
  179. const query = "How do I handle errors in JavaScript?";
  180. const documents: RerankDocument[] = [
  181. { file: "cooking.md", text: "To make a good pasta, boil water and add salt." },
  182. { file: "errors.md", text: "Use try-catch blocks to handle JavaScript errors gracefully." },
  183. { file: "python.md", text: "Python uses try-except for exception handling." },
  184. ];
  185. const result = await llm.rerank(query, documents);
  186. // JavaScript errors doc should score highest
  187. expect(result.results[0]!.file).toBe("errors.md");
  188. expect(result.results[0]!.score).toBeGreaterThan(0.7);
  189. // Python doc might be somewhat relevant (same concept, different language)
  190. // Cooking should be least relevant
  191. expect(result.results[2]!.file).toBe("cooking.md");
  192. });
  193. test("handles empty document list", async () => {
  194. const result = await llm.rerank("test query", []);
  195. expect(result.results).toHaveLength(0);
  196. });
  197. test("handles single document", async () => {
  198. const result = await llm.rerank("test", [{ file: "doc.md", text: "content" }]);
  199. expect(result.results).toHaveLength(1);
  200. expect(result.results[0]!.file).toBe("doc.md");
  201. });
  202. test("preserves original file paths", async () => {
  203. const documents: RerankDocument[] = [
  204. { file: "path/to/doc1.md", text: "content one" },
  205. { file: "another/path/doc2.md", text: "content two" },
  206. ];
  207. const result = await llm.rerank("query", documents);
  208. const files = result.results.map((r) => r.file).sort();
  209. expect(files).toEqual(["another/path/doc2.md", "path/to/doc1.md"]);
  210. });
  211. test("returns scores between 0 and 1", async () => {
  212. const documents: RerankDocument[] = [
  213. { file: "a.md", text: "The quick brown fox jumps over the lazy dog." },
  214. { file: "b.md", text: "Machine learning algorithms process data efficiently." },
  215. { file: "c.md", text: "React components use JSX syntax for rendering." },
  216. ];
  217. const result = await llm.rerank("Tell me about animals", documents);
  218. for (const doc of result.results) {
  219. expect(doc.score).toBeGreaterThanOrEqual(0);
  220. expect(doc.score).toBeLessThanOrEqual(1);
  221. }
  222. });
  223. test("batch reranks multiple documents efficiently", async () => {
  224. // Create 10 documents to verify batch processing works
  225. const documents: RerankDocument[] = Array(10)
  226. .fill(null)
  227. .map((_, i) => ({
  228. file: `doc${i}.md`,
  229. text: `Document number ${i} with some content about topic ${i % 3}`,
  230. }));
  231. const start = Date.now();
  232. const result = await llm.rerank("topic 1", documents);
  233. const elapsed = Date.now() - start;
  234. expect(result.results).toHaveLength(10);
  235. // Verify all documents are returned with valid scores
  236. for (const doc of result.results) {
  237. expect(doc.score).toBeGreaterThanOrEqual(0);
  238. expect(doc.score).toBeLessThanOrEqual(1);
  239. }
  240. // Log timing for monitoring batch performance
  241. console.log(`Batch rerank of 10 docs took ${elapsed}ms`);
  242. });
  243. });
  244. describe("expandQuery", () => {
  245. test("returns query expansions with correct types", async () => {
  246. const result = await llm.expandQuery("test query");
  247. // Result is Queryable[] containing lex, vec, and/or hyde entries
  248. expect(result.length).toBeGreaterThanOrEqual(1);
  249. // Each result should have a valid type
  250. for (const q of result) {
  251. expect(["lex", "vec", "hyde"]).toContain(q.type);
  252. expect(q.text.length).toBeGreaterThan(0);
  253. }
  254. }, 30000); // 30s timeout for model loading
  255. test("can exclude lexical queries", async () => {
  256. const result = await llm.expandQuery("authentication setup", { includeLexical: false });
  257. // Should not contain any 'lex' type entries
  258. const lexEntries = result.filter(q => q.type === "lex");
  259. expect(lexEntries).toHaveLength(0);
  260. });
  261. });
  262. });