/** * @factory/search — embedding provider interface + deterministic stub * (ARCHITECTURE.md §7, search-architecture §7.3). * * The real providers (OpenAI / Google / OSS / custom) live in `@factory/ai` * and are wired into the indexer by the app layer; this package defines the * contract plus a **deterministic stub** for tests and for the * `vector_model = "stub"` dev path. The stub is NOT a real semantic embedding * — it is a bag-of-character-n-gram hash normalized to unit length — but it * is stable, dependency-free (node:crypto) and dimension-exact, which is all * the pipeline needs to be testable end-to-end. * * Recorded decision: the stub dimension (default 64) differs from every real * model (OpenAI 1536/3072, Google 768, OSS discovered). Index mappings must * use the dimension of the model actually in use — the `vector_model` * keyword field is the migration guard (search §7.3), never the mapping. */ import { createHash } from "node:crypto"; import type { EmbeddingProvider } from "./types.js"; /** Dimension of the deterministic stub embedding. */ export const STUB_EMBEDDING_DIMENSION = 64; /** The vector_model tag used for stub embeddings (migration guard). */ export const STUB_VECTOR_MODEL = "stub-v1"; /** * Deterministic embedding: hash character trigrams into `dimension` buckets, * L2-normalize. Pure function of (text, dimension) — no clock, no randomness. */ export function stubEmbed(text: string, dimension: number = STUB_EMBEDDING_DIMENSION): number[] { const vec = new Array(dimension).fill(0); const s = text.toLowerCase().replace(/\s+/g, " ").trim(); // char trigrams (padded) + word unigrams for signal for (let i = 0; i < s.length - 2; i++) { const gram = s.slice(i, i + 3); const h = hashBucket(gram, dimension); vec[h]! += 1; } for (const word of s.split(" ")) { if (word.length === 0) continue; const h = hashBucket("w:" + word, dimension); vec[h]! += 2; // words weigh more than grams } const norm = Math.sqrt(vec.reduce((acc, v) => acc + v * v, 0)); if (norm === 0) return vec; // empty text → zero vector return vec.map((v) => v / norm); } function hashBucket(key: string, dimension: number): number { const digest = createHash("sha256").update(key, "utf8").digest(); let h = 0; for (let i = 0; i < 8; i++) h = (h * 256 + digest[i]!) >>> 0; return h % dimension; } /** * A deterministic, dependency-free `EmbeddingProvider` for tests, local dev * and CI. Determinism guarantee: the same text always yields the same vector * (byte-identical), so test fixtures can hard-code expectations. */ export class StubEmbeddingProvider implements EmbeddingProvider { readonly model: string; readonly dimension: number; constructor(dimension: number = STUB_EMBEDDING_DIMENSION) { this.dimension = dimension; this.model = STUB_VECTOR_MODEL; } async embed(texts: string[]): Promise { return texts.map((t) => stubEmbed(t, this.dimension)); } } /** Cosine similarity in [−1, 1]; zero-safe (returns 0 when either is a zero vector). */ export function cosineSimilarity(a: readonly number[], b: readonly number[]): number { if (a.length !== b.length) { throw new Error(`dimension mismatch: ${a.length} != ${b.length}`); } let dot = 0; let na = 0; let nb = 0; for (let i = 0; i < a.length; i++) { dot += a[i]! * b[i]!; na += a[i]! * a[i]!; nb += b[i]! * b[i]!; } if (na === 0 || nb === 0) return 0; return dot / (Math.sqrt(na) * Math.sqrt(nb)); }