/** * Tests for the content-similarity search surface over the CLIP index * (issue #50). * * The surface is `lib.mldata`: `forFile` reads the full stored payload from * disk, while `similar` and `searchByEmbedding` rank fileIDs by cosine * similarity over the in-RAM `Float32Array` index alone (no disk, no network). * The fixture uses axis-aligned vectors so the correct cosine ranking is * obvious by inspection; cosine ignores magnitude, so `[2, 0, 0]` ranks above * `[0.8, 0.6, 0]` for a `[1, 0, 0]` query. */ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { MLDataStore } from "../../src/library/mldata.js"; import { makeMLDataAPI, type MLDataAPI } from "../../src/library/mlsearch.js"; import type { MLData } from "../../src/mldata-fetch.js"; // A payload shaped like Ente's: a CLIP embedding plus face data that only the // on-disk payload carries (never the RAM index). const payload = (embedding: number[]): MLData => ({ face: { faces: [{ faceID: "f", detection: { box: { x: 0.5 } } }] }, clip: { embedding }, }); // A small fixture index. Directions are chosen so every cosine ranking below // is unambiguous. const fixture = (): Map => new Map([ [10, payload([1, 0, 0])], [20, payload([0.8, 0.6, 0])], [30, payload([0, 1, 0])], [40, payload([-1, 0, 0])], [50, payload([2, 0, 0])], ]); describe("lib.mldata content-similarity search", () => { let dir: string; let store: MLDataStore; let api: MLDataAPI; beforeEach(async () => { dir = mkdtempSync(join(tmpdir(), "quak-mlsearch-")); store = await MLDataStore.open(dir); const updation = new Map([...fixture().keys()].map((id) => [id, 1])); await store.storeFetched(fixture(), updation); api = makeMLDataAPI(() => store); }); afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); it("forFile returns the whole stored payload, or undefined when uncached", async () => { const full = await api.forFile({ fileID: 20 }); expect(full).toBeDefined(); // Face data lives only in the payload, never in the RAM index. expect(full?.face).toBeDefined(); expect(full?.clip).toEqual({ embedding: [0.8, 0.6, 0] }); expect(await api.forFile({ fileID: 999 })).toBeUndefined(); }); it("similar ranks other files by cosine and excludes the query itself", () => { // Query is file 10 = [1, 0, 0]. By cosine: 50 (1.0) > 20 (0.8) > // 30 (0) > 40 (-1); 10 itself is left out. const ranked = api.similar({ fileID: 10 }); expect(ranked.map((r) => r.fileID)).toEqual([50, 20, 30, 40]); // Cosine ignores magnitude: [2,0,0] is a perfect match for [1,0,0]. expect(ranked[0]).toMatchObject({ fileID: 50 }); expect(ranked[0].score).toBeCloseTo(1, 5); }); it("similar honours limit and returns [] for an unindexed file", () => { expect( api.similar({ fileID: 10, limit: 2 }).map((r) => r.fileID), ).toEqual([50, 20]); expect(api.similar({ fileID: 999 })).toEqual([]); }); it("searchByEmbedding ranks the index by cosine to the query vector", () => { // Query [0, 1, 0]: 30 (1.0) > 20 (0.6) > {10, 40, 50} all 0, broken by // ascending fileID. const ranked = api.searchByEmbedding({ embedding: [0, 1, 0] }); expect(ranked.map((r) => r.fileID)).toEqual([30, 20, 10, 40, 50]); expect(ranked[0].score).toBeCloseTo(1, 5); expect( api .searchByEmbedding({ embedding: [0, 1, 0], limit: 2 }) .map((r) => r.fileID), ).toEqual([30, 20]); }); it("searchByEmbedding returns [] for a wrong-length or zero query", () => { expect(api.searchByEmbedding({ embedding: [1, 0] })).toEqual([]); expect(api.searchByEmbedding({ embedding: [0, 0, 0] })).toEqual([]); }); it("degrades to empty results when no ML store is present", async () => { const none = makeMLDataAPI(() => undefined); expect(await none.forFile({ fileID: 10 })).toBeUndefined(); expect(none.similar({ fileID: 10 })).toEqual([]); expect(none.searchByEmbedding({ embedding: [1, 0, 0] })).toEqual([]); }); });