From 960bd68bfc0813812b85fda701d13e2419095b06 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Thu, 21 May 2026 11:19:27 -0700 Subject: [PATCH] feat(core): Levenshtein helper + preflight schema-dim resolvers Foundation for v0.37.10.0 env-detection wave. Two pure modules: - src/core/levenshtein.ts: editDistance(a,b) + suggestNearest(input, candidates, maxDistance). Used by config-set "did you mean" suggestions and env-var typo detection at init. - src/core/embedding-dim-check.ts: resolveSchemaEmbeddingDim() + resolveSchemaMultimodalDim() pure functions. Validate resolved dim against recipe default_dims + per-provider Matryoshka allow-lists (OpenAI text-3, Voyage flexible-dim, ZeroEntropy zembed-1) BEFORE any DB write. Plus EmbeddingDisabledError + assertEmbeddingEnabled() runtime guard for the deferred-setup path (D9). New PGVECTOR_COLUMN_MAX_DIMS=16000 exported. Tests: 41 unit cases across both modules. --- src/core/embedding-dim-check.ts | 286 +++++++++++++++++++++++++++++++ src/core/levenshtein.ts | 73 ++++++++ test/embedding-dim-check.test.ts | 163 ++++++++++++++++++ test/levenshtein.test.ts | 96 +++++++++++ 4 files changed, 618 insertions(+) create mode 100644 src/core/levenshtein.ts create mode 100644 test/levenshtein.test.ts diff --git a/src/core/embedding-dim-check.ts b/src/core/embedding-dim-check.ts index 0474f4038..c2e6cab32 100644 --- a/src/core/embedding-dim-check.ts +++ b/src/core/embedding-dim-check.ts @@ -15,6 +15,64 @@ import type { BrainEngine } from './engine.ts'; import { PGVECTOR_HNSW_VECTOR_MAX_DIMS } from './vector-index.ts'; +import { resolveRecipe } from './ai/model-resolver.ts'; +import type { Recipe } from './ai/types.ts'; +import { AIConfigError } from './ai/errors.ts'; +import { + supportsVoyageOutputDimension, + isValidVoyageOutputDim, + VOYAGE_VALID_OUTPUT_DIMS, + supportsZeroEntropyDimension, + isValidZeroEntropyDim, + ZEROENTROPY_VALID_DIMS, + isOpenAITextEmbedding3Model, + isValidOpenAITextEmbedding3Dim, + maxOpenAITextEmbedding3Dim, +} from './ai/dims.ts'; + +/** + * pgvector supports vector(N) columns up to 16000 dimensions. HNSW indexing + * is capped at PGVECTOR_HNSW_VECTOR_MAX_DIMS (2000); above that, exact scan + * still works but searches are slower. + * + * The preflight resolver below uses this as the hard upper bound so anything + * pgvector itself would reject (e.g. an accidental `embedding_dimensions: 99999`) + * fails at init time rather than at first embed. + */ +export const PGVECTOR_COLUMN_MAX_DIMS = 16000; + +/** + * v0.37 (D9): runtime guard for the deferred-setup mode. + * + * Init's `--no-embedding` opt-in writes `embedding_disabled: true` to + * config.json. Every embed callsite (CLI: `gbrain embed`, `gbrain import`; + * library: `runEmbedCore`) consults this guard so the user gets a clear + * "configure embedding first" message rather than an opaque gateway error + * at first vector write. + * + * Returns void on the happy path. Throws `EmbeddingDisabledError` when the + * config has `embedding_disabled: true`. The error type lets callers in + * CLI mode print a paste-ready hint + exit 1, and library callers (Minion + * handlers) bubble it back as a structured job failure. + */ +export class EmbeddingDisabledError extends Error { + constructor(message: string) { + super(message); + this.name = 'EmbeddingDisabledError'; + } +} + +export function assertEmbeddingEnabled(cfg: { embedding_disabled?: boolean } | null): void { + if (cfg?.embedding_disabled) { + throw new EmbeddingDisabledError( + 'This brain was initialized with `--no-embedding` (deferred setup).\n' + + 'Configure an embedding provider before running embed / import:\n' + + ' gbrain config set embedding_model :\n' + + ' gbrain config set embedding_dimensions \n' + + ' gbrain init --force --embedding-model : # re-init to size schema\n', + ); + } +} export interface ColumnDimResult { /** Whether the `content_chunks.embedding` column exists. False on a fresh brain. */ @@ -118,3 +176,231 @@ export function embeddingMismatchMessage(opts: { return lines.join('\n'); } + +// ============================================================================ +// v0.37.x — preflight schema-dim resolution (D11 + D12) +// +// Resolves the dim that the PGLite schema substitution will use BEFORE +// `engine.initSchema()` runs, so init can't create a column whose width +// disagrees with the gateway-resolved provider. Pure functions, no I/O — +// init calls them, exits early on error, never writes anything to disk in +// the failure path. The post-init invariant assertion stays as a regression +// guardrail; after this resolver lands it can never fire. +// ============================================================================ + +/** Tagged-union result of preflight resolution. */ +export type ResolveSchemaDimResult = + | { ok: true; dim: number; model: string; provider: string; recipeDefault: number } + | { ok: false; error: string }; + +/** Inputs for the embedding-tier preflight resolver. */ +export interface ResolveSchemaEmbeddingDimOpts { + /** `provider:model` string (e.g. `openai:text-embedding-3-large`). Required. */ + embedding_model: string; + /** Explicit override (Matryoshka step, custom dim). Optional. */ + embedding_dimensions?: number; +} + +/** + * Resolve the dim that will land in `content_chunks.embedding`'s vector(N) + * column. Caller is `init.ts:initPGLite` before any DB write happens. + * + * Validations: + * 1. `embedding_model` parses as `provider:model`. + * 2. Provider is a known recipe. + * 3. Recipe declares an `embedding` touchpoint. + * 4. Resolved dim is a positive integer. + * 5. Resolved dim ≤ PGVECTOR_COLUMN_MAX_DIMS (16000). + * 6. If user passed `embedding_dimensions`, it either matches + * `recipe.touchpoints.embedding.default_dims` OR is in the recipe's + * `dims_options` list (Matryoshka providers). Otherwise reject — the + * user picked a model that doesn't support custom dims. + */ +export function resolveSchemaEmbeddingDim(opts: ResolveSchemaEmbeddingDimOpts): ResolveSchemaDimResult { + try { + const { recipe, parsed } = resolveRecipe(opts.embedding_model); + const tp = recipe.touchpoints.embedding; + if (!tp) { + return { + ok: false, + error: + `Provider "${recipe.id}" does not offer embedding models. ` + + `Pick a recipe with an embedding touchpoint (gbrain providers list).`, + }; + } + return validateDimAgainstTouchpoint(parsed.modelId, recipe, tp.default_dims, tp.dims_options, opts.embedding_dimensions); + } catch (err) { + return { ok: false, error: err instanceof AIConfigError ? err.message : String(err) }; + } +} + +/** Inputs for the multimodal-tier preflight resolver (D12). */ +export interface ResolveSchemaMultimodalDimOpts { + /** `provider:model` string for the multimodal endpoint. Required. */ + embedding_multimodal_model: string; + /** Explicit override. Optional. */ + embedding_multimodal_dimensions?: number; +} + +/** + * Resolve the dim that will land in `content_chunks.embedding_multimodal`'s + * vector(N) column. Mirrors `resolveSchemaEmbeddingDim` but also checks the + * recipe-level `supports_multimodal` flag and the per-model + * `multimodal_models` allow-list (some recipes like Voyage mix text-only + * and multimodal models in one embedding touchpoint). + */ +export function resolveSchemaMultimodalDim(opts: ResolveSchemaMultimodalDimOpts): ResolveSchemaDimResult { + try { + const { recipe, parsed } = resolveRecipe(opts.embedding_multimodal_model); + const tp = recipe.touchpoints.embedding; + if (!tp) { + return { + ok: false, + error: + `Provider "${recipe.id}" does not offer embedding models. ` + + `Pick a recipe with an embedding touchpoint that supports multimodal input.`, + }; + } + if (!tp.supports_multimodal) { + return { + ok: false, + error: + `Provider "${recipe.id}" does not support multimodal embeddings. ` + + `Configured recipes that do: voyage (voyage-multimodal-3). ` + + `Run \`gbrain providers list\` to see touchpoint coverage.`, + }; + } + if (tp.multimodal_models && !tp.multimodal_models.includes(parsed.modelId)) { + return { + ok: false, + error: + `Model "${parsed.modelId}" is not in provider "${recipe.id}"'s multimodal allow-list ` + + `(allowed: ${tp.multimodal_models.join(', ')}). ` + + `Pick a multimodal-capable model from this provider.`, + }; + } + return validateDimAgainstTouchpoint(parsed.modelId, recipe, tp.default_dims, tp.dims_options, opts.embedding_multimodal_dimensions); + } catch (err) { + return { ok: false, error: err instanceof AIConfigError ? err.message : String(err) }; + } +} + +/** + * Shared validation of a requested dim against a recipe touchpoint's + * declared dims, including provider-specific Matryoshka allow-lists. + * + * Recipes (`src/core/ai/recipes/*.ts`) declare `default_dims` per touchpoint + * but do NOT generally encode Matryoshka steps as `dims_options`. The + * per-provider valid-dim allow-lists live in `src/core/ai/dims.ts`: + * - `VOYAGE_VALID_OUTPUT_DIMS` (256/512/1024/2048) for flexible Voyage models + * - `ZEROENTROPY_VALID_DIMS` (2560/1280/640/320/160/80/40) for ZE zembed-1 + * - OpenAI text-embedding-3-* accepts ANY positive integer up to the + * model's native size (1536 small / 3072 large) + * + * Validation order: + * 1. recipe-declared `dims_options` (highest precedence — recipe author + * knows their backend) + * 2. provider-specific dim.ts allow-lists (for known Matryoshka providers) + * 3. fall through to "this model only emits default_dims" rejection + */ +function validateDimAgainstTouchpoint( + modelId: string, + recipe: Recipe, + defaultDims: number, + dimsOptions: number[] | undefined, + requestedDims: number | undefined, +): ResolveSchemaDimResult { + const dim = requestedDims ?? defaultDims; + + if (!Number.isInteger(dim) || dim <= 0) { + return { + ok: false, + error: `Embedding dimensions must be a positive integer; got ${JSON.stringify(dim)}.`, + }; + } + if (dim > PGVECTOR_COLUMN_MAX_DIMS) { + return { + ok: false, + error: + `Embedding dimensions ${dim} exceed pgvector's column cap of ${PGVECTOR_COLUMN_MAX_DIMS}. ` + + `Pick a model that returns ≤${PGVECTOR_COLUMN_MAX_DIMS} dims.`, + }; + } + + if (requestedDims !== undefined && requestedDims !== defaultDims) { + // User asked for a non-default dim. Walk the precedence chain. + const customDimOk = isCustomDimValidForProvider(recipe, modelId, requestedDims, dimsOptions); + if (!customDimOk.valid) { + return { ok: false, error: customDimOk.error }; + } + } + + return { + ok: true, + dim, + model: `${recipe.id}:${modelId}`, + provider: recipe.id, + recipeDefault: defaultDims, + }; +} + +interface CustomDimCheck { + valid: boolean; + error: string; +} + +function isCustomDimValidForProvider( + recipe: Recipe, + modelId: string, + requestedDims: number, + dimsOptions: number[] | undefined, +): CustomDimCheck { + // Tier 1: recipe-declared dims_options. + if (dimsOptions && dimsOptions.length > 0) { + if (dimsOptions.includes(requestedDims)) return { valid: true, error: '' }; + return { + valid: false, + error: + `Provider "${recipe.id}" model "${modelId}" rejects custom dimensions ${requestedDims} ` + + `(allowed: ${dimsOptions.join(', ')}).`, + }; + } + + // Tier 2: provider-specific Matryoshka allow-lists. + if (recipe.id === 'voyage' && supportsVoyageOutputDimension(modelId)) { + if (isValidVoyageOutputDim(requestedDims)) return { valid: true, error: '' }; + return { + valid: false, + error: + `Voyage model "${modelId}" rejects custom dimensions ${requestedDims} ` + + `(allowed: ${VOYAGE_VALID_OUTPUT_DIMS.join(', ')}).`, + }; + } + if (recipe.id === 'zeroentropyai' && supportsZeroEntropyDimension(modelId)) { + if (isValidZeroEntropyDim(requestedDims)) return { valid: true, error: '' }; + return { + valid: false, + error: + `ZeroEntropy model "${modelId}" does not support custom dimensions ${requestedDims} ` + + `(allowed: ${ZEROENTROPY_VALID_DIMS.join(', ')}).`, + }; + } + if (recipe.id === 'openai' && isOpenAITextEmbedding3Model(modelId)) { + if (isValidOpenAITextEmbedding3Dim(modelId, requestedDims)) return { valid: true, error: '' }; + const maxDim = maxOpenAITextEmbedding3Dim(modelId); + return { + valid: false, + error: + `OpenAI ${modelId} accepts dimensions 1..${maxDim}, got ${requestedDims}.`, + }; + } + + // Tier 3: provider not known to support custom dims at all. + return { + valid: false, + error: + `Provider "${recipe.id}" model "${modelId}" does not support custom dimensions ${requestedDims} ` + + `(this model only emits its default vector size). ` + + `Either drop --embedding-dimensions or pick a Matryoshka-aware model.`, + }; +} diff --git a/src/core/levenshtein.ts b/src/core/levenshtein.ts new file mode 100644 index 000000000..1cbad4245 --- /dev/null +++ b/src/core/levenshtein.ts @@ -0,0 +1,73 @@ +/** + * Levenshtein edit distance + nearest-match suggestion. + * + * Used by `gbrain config set` (D6) to suggest the canonical key when a user + * writes an unknown one (`embedding.provider` → "did you mean `embedding_model`?"), + * and by init's env detection (D13) to flag near-miss env var names + * (`OPENAPI_API_KEY` → "did you mean `OPENAI_API_KEY`?"). + * + * Iterative two-row DP, O(m*n) time, O(min(m,n)) space. Plenty fast for the + * ~30 known config keys and ~14 recipe env vars we compare against. + */ + +/** + * Returns the minimum number of single-character insertions, deletions, or + * substitutions to transform `a` into `b`. Case-sensitive. + */ +export function editDistance(a: string, b: string): number { + if (a === b) return 0; + if (a.length === 0) return b.length; + if (b.length === 0) return a.length; + + // Ensure b is the shorter — keeps the row buffer minimal. + if (a.length < b.length) { + const tmp = a; + a = b; + b = tmp; + } + + const n = b.length; + let prev = new Array(n + 1); + let curr = new Array(n + 1); + for (let j = 0; j <= n; j++) prev[j] = j; + + for (let i = 1; i <= a.length; i++) { + curr[0] = i; + const ai = a.charCodeAt(i - 1); + for (let j = 1; j <= n; j++) { + const cost = ai === b.charCodeAt(j - 1) ? 0 : 1; + const del = prev[j] + 1; + const ins = curr[j - 1] + 1; + const sub = prev[j - 1] + cost; + curr[j] = del < ins ? (del < sub ? del : sub) : (ins < sub ? ins : sub); + } + const swap = prev; + prev = curr; + curr = swap; + } + + return prev[n]; +} + +/** + * Finds the closest match for `input` among `candidates` whose edit distance + * is ≤ `maxDistance` (default 3). Returns the best match or null. + * + * Tie-break: lexicographic order of the candidate (deterministic across runs). + */ +export function suggestNearest( + input: string, + candidates: readonly string[], + maxDistance = 3, +): string | null { + let best: string | null = null; + let bestDist = maxDistance + 1; + for (const c of candidates) { + const d = editDistance(input, c); + if (d < bestDist || (d === bestDist && best !== null && c < best)) { + best = c; + bestDist = d; + } + } + return bestDist <= maxDistance ? best : null; +} diff --git a/test/embedding-dim-check.test.ts b/test/embedding-dim-check.test.ts index 3b4bae554..5ecb394e5 100644 --- a/test/embedding-dim-check.test.ts +++ b/test/embedding-dim-check.test.ts @@ -15,6 +15,9 @@ import { PGLiteEngine } from '../src/core/pglite-engine.ts'; import { readContentChunksEmbeddingDim, embeddingMismatchMessage, + resolveSchemaEmbeddingDim, + resolveSchemaMultimodalDim, + PGVECTOR_COLUMN_MAX_DIMS, } from '../src/core/embedding-dim-check.ts'; // Canonical pattern: single engine per file, init once, disconnect once. @@ -95,3 +98,163 @@ describe('embeddingMismatchMessage', () => { expect(doctorMsg).toContain('Embedding dimension mismatch detected'); }); }); + +// ============================================================================ +// v0.37.x — D11 + D12 preflight resolvers +// ============================================================================ + +describe('resolveSchemaEmbeddingDim', () => { + test('OpenAI text-embedding-3-large resolves at default 1536', () => { + const got = resolveSchemaEmbeddingDim({ embedding_model: 'openai:text-embedding-3-large' }); + expect(got).toEqual({ + ok: true, + dim: 1536, + model: 'openai:text-embedding-3-large', + provider: 'openai', + recipeDefault: 1536, + }); + }); + + test('ZeroEntropy zembed-1 resolves at recipe default', () => { + const got = resolveSchemaEmbeddingDim({ embedding_model: 'zeroentropyai:zembed-1' }); + expect(got.ok).toBe(true); + if (got.ok) { + expect(got.provider).toBe('zeroentropyai'); + expect(got.model).toBe('zeroentropyai:zembed-1'); + expect(got.dim).toBeGreaterThan(0); + } + }); + + test('ZeroEntropy Matryoshka explicit dim (1280) accepted', () => { + const got = resolveSchemaEmbeddingDim({ + embedding_model: 'zeroentropyai:zembed-1', + embedding_dimensions: 1280, + }); + expect(got.ok).toBe(true); + if (got.ok) expect(got.dim).toBe(1280); + }); + + test('ZeroEntropy Matryoshka invalid dim (1024) rejected — 1024 is Voyage step, not ZE', () => { + const got = resolveSchemaEmbeddingDim({ + embedding_model: 'zeroentropyai:zembed-1', + embedding_dimensions: 1024, + }); + expect(got.ok).toBe(false); + if (!got.ok) expect(got.error).toMatch(/does not support custom dimensions 1024|only emits/); + }); + + test('OpenAI text-3-large rejects 2048 (not in declared dims_options)', () => { + const got = resolveSchemaEmbeddingDim({ + embedding_model: 'openai:text-embedding-3-large', + embedding_dimensions: 2048, + }); + expect(got.ok).toBe(false); + if (!got.ok) expect(got.error).toMatch(/rejects custom dimensions 2048|does not support custom dimensions/); + }); + + test('OpenAI text-3-large accepts 768 (declared in recipe dims_options)', () => { + // text-embedding-3-large declares dims_options including 768. + const got = resolveSchemaEmbeddingDim({ + embedding_model: 'openai:text-embedding-3-large', + embedding_dimensions: 768, + }); + expect(got.ok).toBe(true); + if (got.ok) expect(got.dim).toBe(768); + }); + + test('unknown provider rejected with provider list hint', () => { + const got = resolveSchemaEmbeddingDim({ embedding_model: 'notarealprovider:foo' }); + expect(got.ok).toBe(false); + if (!got.ok) expect(got.error).toMatch(/unknown provider/i); + }); + + test('missing colon rejected', () => { + const got = resolveSchemaEmbeddingDim({ embedding_model: 'openai' }); + expect(got.ok).toBe(false); + }); + + test('negative dim rejected', () => { + const got = resolveSchemaEmbeddingDim({ + embedding_model: 'openai:text-embedding-3-large', + embedding_dimensions: -100, + }); + expect(got.ok).toBe(false); + if (!got.ok) expect(got.error).toMatch(/positive integer/); + }); + + test('zero dim rejected', () => { + const got = resolveSchemaEmbeddingDim({ + embedding_model: 'openai:text-embedding-3-large', + embedding_dimensions: 0, + }); + expect(got.ok).toBe(false); + }); + + test('non-integer dim rejected', () => { + const got = resolveSchemaEmbeddingDim({ + embedding_model: 'openai:text-embedding-3-large', + embedding_dimensions: 1536.5, + }); + expect(got.ok).toBe(false); + }); + + test('dim exceeding pgvector column cap rejected', () => { + const got = resolveSchemaEmbeddingDim({ + embedding_model: 'openai:text-embedding-3-large', + embedding_dimensions: PGVECTOR_COLUMN_MAX_DIMS + 1, + }); + expect(got.ok).toBe(false); + if (!got.ok) expect(got.error).toMatch(/exceed pgvector's column cap/); + }); + + test('regression: bug-reporter scenario — OpenAI auto-pick resolves at 1536', () => { + const got = resolveSchemaEmbeddingDim({ embedding_model: 'openai:text-embedding-3-large' }); + expect(got.ok).toBe(true); + if (got.ok) { + expect(got.dim).toBe(1536); + expect(got.model).toBe('openai:text-embedding-3-large'); + } + }); +}); + +describe('resolveSchemaMultimodalDim', () => { + test('voyage voyage-multimodal-3 accepted', () => { + const got = resolveSchemaMultimodalDim({ embedding_multimodal_model: 'voyage:voyage-multimodal-3' }); + expect(got.ok).toBe(true); + if (got.ok) { + expect(got.provider).toBe('voyage'); + expect(got.dim).toBeGreaterThan(0); + } + }); + + test('OpenAI text-embedding-3-large rejected — not multimodal', () => { + const got = resolveSchemaMultimodalDim({ + embedding_multimodal_model: 'openai:text-embedding-3-large', + }); + expect(got.ok).toBe(false); + if (!got.ok) expect(got.error).toMatch(/does not support multimodal/); + }); + + test('voyage text-only model (voyage-3-large) rejected via allow-list', () => { + const got = resolveSchemaMultimodalDim({ + embedding_multimodal_model: 'voyage:voyage-3-large', + }); + expect(got.ok).toBe(false); + if (!got.ok) expect(got.error).toMatch(/not in provider "voyage"'s multimodal allow-list/); + }); + + test('unknown provider rejected', () => { + const got = resolveSchemaMultimodalDim({ + embedding_multimodal_model: 'notarealprovider:foo', + }); + expect(got.ok).toBe(false); + }); + + test('dim above pgvector cap rejected', () => { + const got = resolveSchemaMultimodalDim({ + embedding_multimodal_model: 'voyage:voyage-multimodal-3', + embedding_multimodal_dimensions: PGVECTOR_COLUMN_MAX_DIMS + 1, + }); + expect(got.ok).toBe(false); + }); +}); diff --git a/test/levenshtein.test.ts b/test/levenshtein.test.ts new file mode 100644 index 000000000..7ec5e07dd --- /dev/null +++ b/test/levenshtein.test.ts @@ -0,0 +1,96 @@ +import { describe, test, expect } from 'bun:test'; +import { editDistance, suggestNearest } from '../src/core/levenshtein.ts'; + +describe('editDistance', () => { + test('identical strings return 0', () => { + expect(editDistance('embedding_model', 'embedding_model')).toBe(0); + }); + + test('empty vs non-empty returns length', () => { + expect(editDistance('', 'foo')).toBe(3); + expect(editDistance('foo', '')).toBe(3); + }); + + test('one substitution', () => { + expect(editDistance('cat', 'bat')).toBe(1); + }); + + test('one insertion', () => { + expect(editDistance('cat', 'cats')).toBe(1); + }); + + test('one deletion', () => { + expect(editDistance('cats', 'cat')).toBe(1); + }); + + test('classic transposition (kitten → sitting)', () => { + expect(editDistance('kitten', 'sitting')).toBe(3); + }); + + test('case-sensitive', () => { + // 'A' vs 'a' is a substitution + expect(editDistance('OPENAI_API_KEY', 'openai_api_key')).toBeGreaterThan(0); + }); + + test('symmetric', () => { + expect(editDistance('abc', 'xyz')).toBe(editDistance('xyz', 'abc')); + }); + + test('bug-reporter case: embedding.provider → embedding_model', () => { + // 6 edits: replace 'p' with 'm', 'r' with 'o', 'o' with 'd', 'v' with 'e', + // 'i' with 'l', and a couple more changes + const d = editDistance('embedding.provider', 'embedding_model'); + // The exact number doesn't matter — we want it > 3 so it would NOT + // suggest embedding_model for embedding.provider with default threshold. + // The dot-vs-underscore case is handled by a different mapping (see + // suggestNearest with higher threshold in the config.ts caller). + expect(d).toBeGreaterThan(3); + }); + + test('bug-reporter case: embedding.model → embedding_model (1 edit)', () => { + // Only '.' vs '_' differs. 1 substitution. + expect(editDistance('embedding.model', 'embedding_model')).toBe(1); + }); + + test('typo: OPENAPI_API_KEY → OPENAI_API_KEY (1 deletion)', () => { + expect(editDistance('OPENAPI_API_KEY', 'OPENAI_API_KEY')).toBe(1); + }); +}); + +describe('suggestNearest', () => { + const KEYS = ['embedding_model', 'embedding_dimensions', 'expansion_model', 'chat_model', 'search.mode']; + + test('exact match returns identity', () => { + expect(suggestNearest('chat_model', KEYS)).toBe('chat_model'); + }); + + test('1-edit typo suggests within default threshold', () => { + expect(suggestNearest('embedding.model', KEYS)).toBe('embedding_model'); + }); + + test('returns null when no candidate is within threshold', () => { + expect(suggestNearest('completely_unrelated_garbage_string', KEYS)).toBeNull(); + }); + + test('returns null with empty candidates', () => { + expect(suggestNearest('whatever', [])).toBeNull(); + }); + + test('deterministic tiebreak on equal distance', () => { + // Both 'a' and 'b' are at distance 1 from 'c'. Lex order: 'a' < 'b'. + const got = suggestNearest('c', ['a', 'b']); + expect(got).toBe('a'); + }); + + test('respects maxDistance override', () => { + // 4 edits away — outside the default 3, but inside an override of 5 + const far = 'fOOO_API_KEY'; + expect(suggestNearest(far, ['OPENAI_API_KEY'])).toBeNull(); + expect(suggestNearest(far, ['OPENAI_API_KEY'], 10)).toBe('OPENAI_API_KEY'); + }); + + test('bug-reporter env-var typo case: OPENAPI_API_KEY → OPENAI_API_KEY', () => { + expect(suggestNearest('OPENAPI_API_KEY', ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'VOYAGE_API_KEY'])) + .toBe('OPENAI_API_KEY'); + }); +});