import { describe, expect, it } from "vitest"; import { contentMapping, DEFAULT_KNN_EF_CONSTRUCTION, DEFAULT_KNN_M, DEFAULT_VECTOR_DIMENSION, TYPE_ENUM, vectorDimensionMatches, } from "./mapping.js"; import { SEARCH_TYPES } from "./types.js"; function props(m: Record): Record { return ((m.mappings as { properties: Record }).properties) as Record; } describe("contentMapping", () => { it("uses the OpenSearch knn_vector + HNSW shape (2.15 — no dense_vector)", () => { const m = contentMapping(); const p = props(m); // TASK-0100 S5: the live OpenSearch (2.15) has no `dense_vector` handler // (that is Elasticsearch's name) — the 1.x `knn_vector` + `method` HNSW // form is what this distribution accepts (verified live: PUT 200). // // TASK-0125 (verified live against 2.15): engine = **faiss** — the hybrid // kNN leg pre-filters candidates by the tenant + visibility clause, and // nmslib hard-rejects kNN filters ("Engine [NMSLIB] does not support // filters"); FAISS is the only engine that supports `knn.filter`. And // space_type = **l2** — FAISS on 2.15 rejects `cosinesimil` and // `inner_product`; l2 ranks identically to cosine for the L2-normalized // real embeddings (‖a−b‖² = 2−2·cosθ). expect(p.vector).toEqual( expect.objectContaining({ type: "knn_vector", dimension: DEFAULT_VECTOR_DIMENSION, method: expect.objectContaining({ name: "hnsw", space_type: "l2", engine: "faiss", }), }), ); const method = (p.vector as { method: { parameters: { m: number; ef_construction: number } } }).method; expect(method.parameters.m).toBe(DEFAULT_KNN_M); expect(method.parameters.ef_construction).toBe(DEFAULT_KNN_EF_CONSTRUCTION); }); it("matches the camelCase SearchDoc field names (the layout decision)", () => { const p = props(contentMapping()); for (const field of [ "docId", "type", "id", "workspaceId", "teamId", "title", "body", "chunkId", "chunkIndex", "parentDocId", "vectorModel", "vector", "grants", "archived", "pageId", "blockId", "databaseId", "recordId", "createdBy", "createdByName", "icon", "url", "size", "mimeType", "createdAt", "updatedAt", ]) { expect(p, `missing field ${field}`).toHaveProperty(field); } // the snake_case names the Phase-0 query builders drifted to are NOT here for (const field of ["workspace_id", "team_id", "updated_at", "created_by", "parent_doc_id"]) { expect(p, `stale snake_case field ${field}`).not.toHaveProperty(field); } }); it("backs prefix + fuzzy with title.keyword", () => { const p = props(contentMapping()); const title = p.title as { fields: { keyword: { type: string } } }; expect(title.fields.keyword.type).toBe("keyword"); }); it("types the 7 public doc types + chunk (TASK-0099)", () => { const p = props(contentMapping()); // TASK-0100 S5: OpenSearch 2.15 rejects a mapping-level `enum` param on // keyword mappers (the index could never be created), so `type` is a // plain keyword. The allowed set (TYPE_ENUM) is still the source of truth // for the application layer — it includes "chunk" but the public // SEARCH_TYPES never request it. expect(p.type).toEqual({ type: "keyword" }); // "chunk" is an accepted doc type (the index must accept chunk docs) but // NOT in the public SEARCH_TYPES (keyword search can never request them). expect(TYPE_ENUM).toContain("chunk"); expect(SEARCH_TYPES).not.toContain("chunk" as never); // chunkId is a plain keyword (the chunk's own stable id). expect(p.chunkId).toEqual({ type: "keyword" }); }); it("keeps the 1 s refresh interval (the lag SLA)", () => { const m = contentMapping(); const settings = (m.settings as { index: { refresh_interval: string; knn: boolean } }).index; expect(settings.refresh_interval).toBe("1s"); expect(settings.knn).toBe(true); }); it("honours the vector tuning options", () => { const p = props(contentMapping({ dimension: 768, m: 32, efConstruction: 128 })); expect((p.vector as { dimension: number }).dimension).toBe(768); const method = (p.vector as { method: { parameters: { m: number; ef_construction: number } } }).method; expect(method.parameters.m).toBe(32); expect(method.parameters.ef_construction).toBe(128); }); }); describe("vectorDimensionMatches", () => { it("guards the alias-rollover (dims are immutable)", () => { expect(vectorDimensionMatches({ mappings: { properties: { vector: { dimension: 1536 } } } }, 1536)).toBe(true); expect(vectorDimensionMatches({ mappings: { properties: { vector: { dimension: 1024 } } } }, 1536)).toBe(false); expect(vectorDimensionMatches({}, 1536)).toBe(false); }); });