mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
* feat: migration v68 — eval_candidates.embedding_column Schema migration ALTERs eval_candidates to add a nullable embedding_column TEXT column. Per-row capture metadata so `gbrain eval replay` reproduces the same column the capture ran against (D16 / CDX-10). NULL-tolerant: pre-v0.36 rows fall back to current default. Renumbered v67→v68 because master claimed v67 for facts_typed_claim_columns during this branch's lifetime. PGLite parity via sqlFor.pglite — same ALTER IF NOT EXISTS. * feat: dynamic embedding column — core (resolver, types, gateway, engines) The read-path foundation for routing search through any populated embedding column, not just OpenAI 1536. src/core/search/embedding-column.ts (new) is the canonical seam. Single source of truth for column → provider/dim/type lookup. Validates registry keys via regex (/^[a-z_][a-z0-9_]*$/), uses Object.create(null) + Object.hasOwn so 'constructor' and other inherited names can't masquerade as registered columns. Identifier-quoting on SQL interpolation as defense in depth. src/core/types.ts widens SearchOpts.embeddingColumn to accept ResolvedColumn descriptors at the engine boundary; adds EmbeddingColumnConfig + ResolvedColumn exports. src/core/config.ts merges embedding_columns + search_embedding_column from the DB plane via loadConfigWithEngine, mirroring the existing embedding_multimodal_model pattern. Handles the no-file case so env-only Postgres installs see DB-plane overrides (codex /ship #3). src/core/ai/gateway.ts: embedQuery(text, opts) + embed(texts, opts) accept embeddingModel + dimensions overrides. isAvailable(touchpoint, modelOverride?) so hybrid asks 'is the active column's provider reachable?' not 'is the global default reachable?' (CDX-4 / D10). Engines: searchVector accepts ResolvedColumn descriptors via normalizeEngineColumn; engine code is config-free and unit-testable. getEmbeddingsByChunkIds(ids, column?) so cosineReScore hydrates from the active column instead of always 'embedding' (CDX-3 / D9). Identifier-quoting belt at the SQL boundary. src/core/eval-capture.ts threads embedding_column from hybridSearch meta into the persisted capture row. * feat: dynamic embedding column — integration (hybrid, ops, doctor) Wires the resolver into hybridSearch, the query op, doctor, and the config command. src/core/search/hybrid.ts: resolves the column once at the boundary, threads the descriptor into engine calls, routes embedQuery through the resolved column's provider/dims, and calls isCacheSafe (not isDefaultColumn) for cache skip so user overrides of the 'embedding' builtin can't leak across vector spaces (CDX-4). cosineReScore now hydrates from the active column. src/core/search/mode.ts: KNOBS_HASH_VERSION 2→3, append-only new fields col= and prov= alongside floor_ratio. Cache rows from different columns or providers now sit in different keyspaces — cross-column contamination impossible. src/core/operations.ts: query op accepts embedding_column param for per-call A/B benchmarking. search op (keyword-only) deliberately does NOT (CDX-9 / D15) — would be silent UX. src/commands/doctor.ts: new embedding_column_registry check. Batch format_type probe (D13) catches dim drift that information_schema.columns.udt_name can't. Batch pg_indexes probe (D5) warns on missing HNSW. Coverage % on active column, gates at <90% (D14), short-circuits on empty brains (codex /ship #5). src/commands/config.ts: validates embedding_columns JSON shape at set time, runs the coverage gate when setting search_embedding_column, uses Object.hasOwn for the registry lookup. src/commands/eval-replay.ts: replay re-runs queries against the captured embedding_column so post-flip-config replays don't surface as false-positive regressions. * test: dynamic embedding column — unit + e2e coverage 50 unit cases for the resolver (resolution chain, registry merge, validation, prototype pollution, descriptor passthrough, isCacheSafe, normalizeEngineColumn). 8 gateway override cases — embeddingModel + dimensions flow into providerOptions, isAvailable(touchpoint, override) routes to the right recipe, unknown models throw clean. 4 cosineReScore + 6 ops + 5 knobs-hash + 7 mode + 9 PGLite E2E + 7 Postgres E2E + 5 eval-replay column metadata. Postgres E2E (gated on DATABASE_URL) covers halfvec(2560) end-to-end on real pgvector, EXPLAIN-visible HNSW index on the alternate column, format_type-based dim drift catch, and the <90% coverage gate. Pins every codex /ship fix: prototype-pollution rejection ('constructor' as column name), descriptor passthrough validation (rejects SQL-shaped strings in dimensions), isCacheSafe semantics (space-based, not name-based). Total: 141 new + extended cases, all green. * chore: bump version and changelog (v0.36.3.0) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: sync to v0.36.3.0 Add CLAUDE.md key-files entry for src/core/search/embedding-column.ts. Annotate hybrid.ts, gateway.ts, doctor.ts, and migrate.ts entries with v0.36.3.0 wave changes (ResolvedColumn threading, embedQuery model override, embedding_column_registry check, migration v68). Document knobs_hash v=2 → v=3 bump under the Search Mode section. Regenerate llms-full.txt from the updated CLAUDE.md so the auto-checked bundle matches source (build-llms.test.ts CI guard). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ci): two CI failures from v0.36.3.0 1. test/loadConfig-merge.test.ts: update the 'returns null when base config is null' contract test. Pre-v0.36 the function returned null for null base; the codex /ship #3 fix changed that to synthesize a minimal `{ engine: 'postgres' }` so env-only installs see DB-plane overrides. Test now pins the new contract + adds a round-trip case asserting the merge actually surfaces `embedding_columns` / `search_embedding_column` set via gbrain config set on a null base. 2. test/schema-bootstrap-coverage.test.ts was failing because eval_candidates.embedding_column (added by migration v68) wasn't covered by applyForwardReferenceBootstrap. Fix: add the column to PGLITE_SCHEMA_SQL's eval_candidates CREATE TABLE definition (and src/schema.sql for parity) so fresh installs get it natively. The coverage test's third tier (schemaCreateTableCols) now finds it. Regenerated schema-embedded.ts via bun run build:schema. Schema-blob path is cleaner than COLUMN_EXEMPTIONS — fresh installs skip the migration entirely; upgrade installs still run v68. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
512 lines
19 KiB
TypeScript
512 lines
19 KiB
TypeScript
/**
|
|
* v0.36 — embedding column resolver tests.
|
|
*
|
|
* Pins:
|
|
* - D2/D11: resolver returns descriptor (name, type, dimensions,
|
|
* embeddingModel).
|
|
* - D3: buildVectorCastFragment produces correct cast string per type.
|
|
* - D11: builtins (`embedding`, `embedding_image`) always present.
|
|
* - D12: registry-key regex + field validation reject malicious input.
|
|
* - D12: identifier-quoting handles embedded quotes safely.
|
|
* - Resolution chain: opts > cfg.search_embedding_column > 'embedding'.
|
|
* - normalizeEngineColumn: descriptor-passthrough + legacy literals +
|
|
* throw on unknown string.
|
|
*/
|
|
|
|
import { describe, test, expect } from 'bun:test';
|
|
import {
|
|
resolveEmbeddingColumn,
|
|
getEmbeddingColumnRegistry,
|
|
buildVectorCastFragment,
|
|
quoteIdentifier,
|
|
validateColumnKey,
|
|
validateColumnConfig,
|
|
normalizeEngineColumn,
|
|
EmbeddingColumnNotRegisteredError,
|
|
EmbeddingColumnConfigError,
|
|
COLUMN_NAME_REGEX,
|
|
ALLOWED_COLUMN_TYPES,
|
|
MAX_DIMENSIONS,
|
|
DEFAULT_COLUMN_NAME,
|
|
isDefaultColumn,
|
|
isCacheSafe,
|
|
isBuiltinColumn,
|
|
} from '../../src/core/search/embedding-column.ts';
|
|
import type { GBrainConfig } from '../../src/core/config.ts';
|
|
import type { ResolvedColumn } from '../../src/core/types.ts';
|
|
|
|
function cfg(overrides: Partial<GBrainConfig> = {}): GBrainConfig {
|
|
return { engine: 'pglite', ...overrides };
|
|
}
|
|
|
|
describe('resolveEmbeddingColumn — resolution chain', () => {
|
|
test('default fallback returns "embedding"', () => {
|
|
const r = resolveEmbeddingColumn(undefined, cfg());
|
|
expect(r.name).toBe('embedding');
|
|
expect(r.type).toBe('vector');
|
|
});
|
|
|
|
test('cfg.search_embedding_column wins over default', () => {
|
|
const r = resolveEmbeddingColumn(undefined, cfg({
|
|
search_embedding_column: 'embedding_voyage',
|
|
embedding_columns: {
|
|
embedding_voyage: { provider: 'voyage:voyage-3-large', dimensions: 1024, type: 'vector' },
|
|
},
|
|
}));
|
|
expect(r.name).toBe('embedding_voyage');
|
|
expect(r.embeddingModel).toBe('voyage:voyage-3-large');
|
|
expect(r.dimensions).toBe(1024);
|
|
});
|
|
|
|
test('opts.embeddingColumn wins over cfg.search_embedding_column', () => {
|
|
const r = resolveEmbeddingColumn(
|
|
{ embeddingColumn: 'embedding_voyage' },
|
|
cfg({
|
|
search_embedding_column: 'embedding_zeroentropy',
|
|
embedding_columns: {
|
|
embedding_voyage: { provider: 'voyage:voyage-3-large', dimensions: 1024, type: 'vector' },
|
|
embedding_zeroentropy: { provider: 'zeroentropyai:zembed-1', dimensions: 2560, type: 'halfvec' },
|
|
},
|
|
}),
|
|
);
|
|
expect(r.name).toBe('embedding_voyage');
|
|
});
|
|
|
|
test('unknown name throws EmbeddingColumnNotRegisteredError with hint', () => {
|
|
let err: EmbeddingColumnNotRegisteredError | null = null;
|
|
try {
|
|
resolveEmbeddingColumn({ embeddingColumn: 'nonexistent' }, cfg());
|
|
} catch (e) {
|
|
err = e as EmbeddingColumnNotRegisteredError;
|
|
}
|
|
expect(err).toBeTruthy();
|
|
expect(err?.code).toBe('embedding_column_not_registered');
|
|
expect(err?.columnName).toBe('nonexistent');
|
|
expect(err?.validColumns).toEqual(['embedding', 'embedding_image']);
|
|
expect(err?.message).toContain('Declared columns:');
|
|
expect(err?.message).toContain('gbrain config set');
|
|
});
|
|
|
|
test('SQL-injection-shaped name rejected before registry lookup', () => {
|
|
expect(() =>
|
|
resolveEmbeddingColumn(
|
|
{ embeddingColumn: 'embedding"; DROP TABLE pages; --' },
|
|
cfg(),
|
|
),
|
|
).toThrow(EmbeddingColumnNotRegisteredError);
|
|
});
|
|
|
|
test('descriptor passthrough: ResolvedColumn returned as-is', () => {
|
|
const descriptor: ResolvedColumn = {
|
|
name: 'embedding_custom',
|
|
type: 'halfvec',
|
|
dimensions: 2560,
|
|
embeddingModel: 'zeroentropyai:zembed-1',
|
|
};
|
|
const r = resolveEmbeddingColumn({ embeddingColumn: descriptor }, cfg());
|
|
expect(r).toEqual(descriptor);
|
|
});
|
|
});
|
|
|
|
describe('getEmbeddingColumnRegistry — builtins + merge', () => {
|
|
test('builtin embedding always present even with empty user config', () => {
|
|
const reg = getEmbeddingColumnRegistry(cfg());
|
|
expect(reg.embedding).toBeDefined();
|
|
expect(reg.embedding!.type).toBe('vector');
|
|
expect(reg.embedding!.dimensions).toBe(1536);
|
|
});
|
|
|
|
test('builtin embedding_image always present with 1024d vector', () => {
|
|
const reg = getEmbeddingColumnRegistry(cfg());
|
|
expect(reg.embedding_image).toBeDefined();
|
|
expect(reg.embedding_image!.type).toBe('vector');
|
|
expect(reg.embedding_image!.dimensions).toBe(1024);
|
|
});
|
|
|
|
test('builtin embedding derives provider from cfg.embedding_model', () => {
|
|
const reg = getEmbeddingColumnRegistry(
|
|
cfg({ embedding_model: 'voyage:voyage-3-large', embedding_dimensions: 1024 }),
|
|
);
|
|
expect(reg.embedding!.provider).toBe('voyage:voyage-3-large');
|
|
expect(reg.embedding!.dimensions).toBe(1024);
|
|
});
|
|
|
|
test('builtin embedding_image derives provider from cfg.embedding_multimodal_model', () => {
|
|
const reg = getEmbeddingColumnRegistry(
|
|
cfg({ embedding_multimodal_model: 'voyage:voyage-multimodal-3' }),
|
|
);
|
|
expect(reg.embedding_image!.provider).toBe('voyage:voyage-multimodal-3');
|
|
});
|
|
|
|
test('user-declared columns merge with builtins', () => {
|
|
const reg = getEmbeddingColumnRegistry(
|
|
cfg({
|
|
embedding_columns: {
|
|
embedding_voyage: { provider: 'voyage:voyage-3-large', dimensions: 1024, type: 'vector' },
|
|
},
|
|
}),
|
|
);
|
|
expect(Object.keys(reg).sort()).toEqual(['embedding', 'embedding_image', 'embedding_voyage']);
|
|
});
|
|
|
|
test('user override wins on conflict (override embedding builtin)', () => {
|
|
const reg = getEmbeddingColumnRegistry(
|
|
cfg({
|
|
embedding_columns: {
|
|
embedding: { provider: 'voyage:voyage-3-large', dimensions: 1024, type: 'vector' },
|
|
},
|
|
}),
|
|
);
|
|
expect(reg.embedding!.provider).toBe('voyage:voyage-3-large');
|
|
expect(reg.embedding!.dimensions).toBe(1024);
|
|
});
|
|
|
|
test('halfvec column with high dim accepted', () => {
|
|
const reg = getEmbeddingColumnRegistry(
|
|
cfg({
|
|
embedding_columns: {
|
|
embedding_ze: { provider: 'zeroentropyai:zembed-1', dimensions: 2560, type: 'halfvec' },
|
|
},
|
|
}),
|
|
);
|
|
expect(reg.embedding_ze!.type).toBe('halfvec');
|
|
expect(reg.embedding_ze!.dimensions).toBe(2560);
|
|
});
|
|
});
|
|
|
|
describe('D12 — defense-in-depth validation', () => {
|
|
describe('validateColumnKey', () => {
|
|
test('accepts lowercase identifier', () => {
|
|
expect(() => validateColumnKey('embedding_voyage')).not.toThrow();
|
|
expect(() => validateColumnKey('a')).not.toThrow();
|
|
expect(() => validateColumnKey('_underscore_first')).not.toThrow();
|
|
expect(() => validateColumnKey('mix_of_letters_and_123')).not.toThrow();
|
|
});
|
|
|
|
test('rejects keys with quotes (SQL injection vector)', () => {
|
|
expect(() => validateColumnKey('embedding"; DROP --')).toThrow(EmbeddingColumnConfigError);
|
|
expect(() => validateColumnKey("embedding'")).toThrow(EmbeddingColumnConfigError);
|
|
});
|
|
|
|
test('rejects keys with uppercase', () => {
|
|
expect(() => validateColumnKey('Embedding')).toThrow(EmbeddingColumnConfigError);
|
|
expect(() => validateColumnKey('EMBEDDING_VOYAGE')).toThrow(EmbeddingColumnConfigError);
|
|
});
|
|
|
|
test('rejects keys starting with digits', () => {
|
|
expect(() => validateColumnKey('1embedding')).toThrow(EmbeddingColumnConfigError);
|
|
});
|
|
|
|
test('rejects keys with hyphens, spaces, special chars', () => {
|
|
expect(() => validateColumnKey('embed-voyage')).toThrow(EmbeddingColumnConfigError);
|
|
expect(() => validateColumnKey('embed voyage')).toThrow(EmbeddingColumnConfigError);
|
|
expect(() => validateColumnKey('embed.voyage')).toThrow(EmbeddingColumnConfigError);
|
|
});
|
|
|
|
test('rejects empty key', () => {
|
|
expect(() => validateColumnKey('')).toThrow(EmbeddingColumnConfigError);
|
|
});
|
|
});
|
|
|
|
describe('validateColumnConfig', () => {
|
|
test('accepts valid config', () => {
|
|
expect(() =>
|
|
validateColumnConfig('embedding_voyage', {
|
|
provider: 'voyage:voyage-3-large',
|
|
dimensions: 1024,
|
|
type: 'vector',
|
|
}),
|
|
).not.toThrow();
|
|
});
|
|
|
|
test('rejects bad type', () => {
|
|
expect(() =>
|
|
validateColumnConfig('embedding_voyage', {
|
|
provider: 'voyage:voyage-3-large',
|
|
dimensions: 1024,
|
|
type: 'jsonb' as 'vector',
|
|
}),
|
|
).toThrow(EmbeddingColumnConfigError);
|
|
});
|
|
|
|
test('rejects bad dimensions (zero/negative/too-large)', () => {
|
|
const base = { provider: 'voyage:voyage-3-large', type: 'vector' as const };
|
|
expect(() => validateColumnConfig('x', { ...base, dimensions: 0 })).toThrow();
|
|
expect(() => validateColumnConfig('x', { ...base, dimensions: -5 })).toThrow();
|
|
expect(() => validateColumnConfig('x', { ...base, dimensions: MAX_DIMENSIONS + 1 })).toThrow();
|
|
expect(() => validateColumnConfig('x', { ...base, dimensions: 1.5 as number })).toThrow();
|
|
});
|
|
|
|
test('rejects bad provider (empty, missing colon, missing model)', () => {
|
|
const base = { dimensions: 1024, type: 'vector' as const };
|
|
expect(() => validateColumnConfig('x', { ...base, provider: '' })).toThrow();
|
|
expect(() => validateColumnConfig('x', { ...base, provider: 'voyage' })).toThrow();
|
|
expect(() => validateColumnConfig('x', { ...base, provider: 'voyage:' })).toThrow();
|
|
expect(() => validateColumnConfig('x', { ...base, provider: ':voyage-3-large' })).toThrow();
|
|
});
|
|
|
|
test('rejects non-object shapes (array, null, scalar)', () => {
|
|
expect(() => validateColumnConfig('x', null)).toThrow();
|
|
expect(() => validateColumnConfig('x', [])).toThrow();
|
|
expect(() => validateColumnConfig('x', 'string')).toThrow();
|
|
expect(() => validateColumnConfig('x', 42)).toThrow();
|
|
});
|
|
});
|
|
|
|
test('registry load throws when any entry is invalid', () => {
|
|
expect(() =>
|
|
getEmbeddingColumnRegistry(
|
|
cfg({
|
|
embedding_columns: {
|
|
'embedding"; DROP --': { provider: 'voyage:voyage-3-large', dimensions: 1024, type: 'vector' },
|
|
},
|
|
}),
|
|
),
|
|
).toThrow(EmbeddingColumnConfigError);
|
|
});
|
|
});
|
|
|
|
describe('D3 — buildVectorCastFragment + quoteIdentifier', () => {
|
|
test('vector type emits $1::vector cast', () => {
|
|
const r: ResolvedColumn = { name: 'embedding', type: 'vector', dimensions: 1536, embeddingModel: '' };
|
|
const { col, castSql } = buildVectorCastFragment(r);
|
|
expect(col).toBe('"embedding"');
|
|
expect(castSql).toBe('$1::vector');
|
|
});
|
|
|
|
test('halfvec type emits $1::halfvec(N) cast', () => {
|
|
const r: ResolvedColumn = { name: 'embedding_ze', type: 'halfvec', dimensions: 2560, embeddingModel: 'zeroentropyai:zembed-1' };
|
|
const { col, castSql } = buildVectorCastFragment(r);
|
|
expect(col).toBe('"embedding_ze"');
|
|
expect(castSql).toBe('$1::halfvec(2560)');
|
|
});
|
|
|
|
test('quoteIdentifier wraps in double quotes', () => {
|
|
expect(quoteIdentifier('embedding')).toBe('"embedding"');
|
|
expect(quoteIdentifier('embedding_voyage')).toBe('"embedding_voyage"');
|
|
});
|
|
|
|
test('quoteIdentifier doubles embedded quotes (defense belt)', () => {
|
|
// Even though regex prevents this from reaching here in practice,
|
|
// the quoting belt handles a quoted-string-break attempt.
|
|
expect(quoteIdentifier('embed"ding')).toBe('"embed""ding"');
|
|
});
|
|
});
|
|
|
|
describe('normalizeEngineColumn — engine-side legacy converter', () => {
|
|
test('undefined returns builtin embedding descriptor', () => {
|
|
const r = normalizeEngineColumn(undefined);
|
|
expect(r.name).toBe('embedding');
|
|
expect(r.type).toBe('vector');
|
|
});
|
|
|
|
test("'embedding' literal returns builtin descriptor", () => {
|
|
const r = normalizeEngineColumn('embedding');
|
|
expect(r.name).toBe('embedding');
|
|
expect(r.type).toBe('vector');
|
|
});
|
|
|
|
test("'embedding_image' literal returns 1024d vector descriptor", () => {
|
|
const r = normalizeEngineColumn('embedding_image');
|
|
expect(r.name).toBe('embedding_image');
|
|
expect(r.type).toBe('vector');
|
|
expect(r.dimensions).toBe(1024);
|
|
});
|
|
|
|
test('ResolvedColumn descriptor passes through', () => {
|
|
const descriptor: ResolvedColumn = {
|
|
name: 'embedding_ze',
|
|
type: 'halfvec',
|
|
dimensions: 2560,
|
|
embeddingModel: 'zeroentropyai:zembed-1',
|
|
};
|
|
expect(normalizeEngineColumn(descriptor)).toEqual(descriptor);
|
|
});
|
|
|
|
test('unknown raw string throws (engine purity contract)', () => {
|
|
// Strings other than legacy literals must NEVER reach the engine.
|
|
// The resolver lives at hybrid/op boundary; the engine throws if
|
|
// a caller bypassed it.
|
|
expect(() => normalizeEngineColumn('embedding_voyage' as string)).toThrow(
|
|
EmbeddingColumnNotRegisteredError,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('helpers', () => {
|
|
test('isDefaultColumn true only for "embedding"', () => {
|
|
const def: ResolvedColumn = { name: 'embedding', type: 'vector', dimensions: 1536, embeddingModel: '' };
|
|
const alt: ResolvedColumn = { name: 'embedding_voyage', type: 'vector', dimensions: 1024, embeddingModel: 'v' };
|
|
expect(isDefaultColumn(def)).toBe(true);
|
|
expect(isDefaultColumn(alt)).toBe(false);
|
|
});
|
|
|
|
test('isBuiltinColumn matches both builtins exactly', () => {
|
|
expect(isBuiltinColumn('embedding')).toBe(true);
|
|
expect(isBuiltinColumn('embedding_image')).toBe(true);
|
|
expect(isBuiltinColumn('embedding_voyage')).toBe(false);
|
|
});
|
|
|
|
test('exported constants are stable', () => {
|
|
expect(DEFAULT_COLUMN_NAME).toBe('embedding');
|
|
expect(ALLOWED_COLUMN_TYPES.has('vector')).toBe(true);
|
|
expect(ALLOWED_COLUMN_TYPES.has('halfvec')).toBe(true);
|
|
expect(MAX_DIMENSIONS).toBe(8192);
|
|
expect(COLUMN_NAME_REGEX.test('embedding_voyage')).toBe(true);
|
|
expect(COLUMN_NAME_REGEX.test('Embedding')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('codex /ship #1 — prototype-pollution-safe registry', () => {
|
|
test('resolver rejects "constructor" even though regex accepts it', () => {
|
|
// The regex `^[a-z_][a-z0-9_]*$` matches "constructor" — but the
|
|
// registry uses Object.create(null) + Object.hasOwn so Object's
|
|
// inherited members don't masquerade as registered columns.
|
|
expect(() =>
|
|
resolveEmbeddingColumn({ embeddingColumn: 'constructor' }, cfg()),
|
|
).toThrow(EmbeddingColumnNotRegisteredError);
|
|
});
|
|
|
|
test('resolver rejects other inherited names (toString, hasOwnProperty)', () => {
|
|
for (const name of ['tostring', 'hasownproperty', 'isprototypeof', 'valueof']) {
|
|
expect(() =>
|
|
resolveEmbeddingColumn({ embeddingColumn: name }, cfg()),
|
|
).toThrow(EmbeddingColumnNotRegisteredError);
|
|
}
|
|
});
|
|
|
|
test('getEmbeddingColumnRegistry returns a null-prototype object', () => {
|
|
const reg = getEmbeddingColumnRegistry(cfg());
|
|
// No Object.prototype inheritance — direct prototype access returns null.
|
|
expect(Object.getPrototypeOf(reg)).toBeNull();
|
|
// Inherited properties are genuinely absent.
|
|
expect((reg as any).constructor).toBeUndefined();
|
|
expect((reg as any).toString).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('codex /ship #2 — descriptor passthrough validates', () => {
|
|
test('passthrough re-validates name regex', () => {
|
|
const bad: ResolvedColumn = {
|
|
name: 'embedding"; DROP TABLE pages; --',
|
|
type: 'vector',
|
|
dimensions: 1536,
|
|
embeddingModel: 'voyage:voyage-3-large',
|
|
};
|
|
expect(() =>
|
|
resolveEmbeddingColumn({ embeddingColumn: bad }, cfg()),
|
|
).toThrow(EmbeddingColumnNotRegisteredError);
|
|
});
|
|
|
|
test('passthrough re-validates type field (rejects unknown)', () => {
|
|
const bad = {
|
|
name: 'embedding_voyage',
|
|
type: 'jsonb',
|
|
dimensions: 1024,
|
|
embeddingModel: 'voyage:voyage-3-large',
|
|
} as unknown as ResolvedColumn;
|
|
expect(() =>
|
|
resolveEmbeddingColumn({ embeddingColumn: bad }, cfg()),
|
|
).toThrow(EmbeddingColumnConfigError);
|
|
});
|
|
|
|
test('passthrough re-validates dimensions field (rejects out-of-range)', () => {
|
|
const bad: ResolvedColumn = {
|
|
name: 'embedding_voyage',
|
|
type: 'vector',
|
|
dimensions: -5,
|
|
embeddingModel: 'voyage:voyage-3-large',
|
|
};
|
|
expect(() =>
|
|
resolveEmbeddingColumn({ embeddingColumn: bad }, cfg()),
|
|
).toThrow(EmbeddingColumnConfigError);
|
|
});
|
|
|
|
test('passthrough re-validates dimensions field (rejects SQL-shaped string)', () => {
|
|
const bad = {
|
|
name: 'embedding_voyage',
|
|
type: 'halfvec',
|
|
dimensions: '1); DROP TABLE pages; --',
|
|
embeddingModel: 'voyage:voyage-3-large',
|
|
} as unknown as ResolvedColumn;
|
|
expect(() =>
|
|
resolveEmbeddingColumn({ embeddingColumn: bad }, cfg()),
|
|
).toThrow(EmbeddingColumnConfigError);
|
|
});
|
|
|
|
test('valid descriptor passes through unchanged', () => {
|
|
const good: ResolvedColumn = {
|
|
name: 'embedding_ze',
|
|
type: 'halfvec',
|
|
dimensions: 2560,
|
|
embeddingModel: 'zeroentropyai:zembed-1',
|
|
};
|
|
expect(resolveEmbeddingColumn({ embeddingColumn: good }, cfg())).toEqual(good);
|
|
});
|
|
});
|
|
|
|
describe('codex /ship #4 — isCacheSafe (embedding-space-based skip)', () => {
|
|
test('default name + matching dim + matching model → safe', () => {
|
|
const r: ResolvedColumn = {
|
|
name: 'embedding',
|
|
type: 'vector',
|
|
dimensions: 1536,
|
|
embeddingModel: 'openai:text-embedding-3-large',
|
|
};
|
|
expect(isCacheSafe(r, cfg())).toBe(true);
|
|
});
|
|
|
|
test('non-default name → unsafe', () => {
|
|
const r: ResolvedColumn = {
|
|
name: 'embedding_voyage',
|
|
type: 'vector',
|
|
dimensions: 1024,
|
|
embeddingModel: 'voyage:voyage-3-large',
|
|
};
|
|
expect(isCacheSafe(r, cfg())).toBe(false);
|
|
});
|
|
|
|
test('default name BUT overridden to different dim → unsafe', () => {
|
|
// User overrode the `embedding` builtin to point at a 1024-dim Voyage
|
|
// column. Name is still 'embedding' but the cache table is sized for
|
|
// 1536d (or whatever the brain's cfg dim was at init). UNSAFE.
|
|
const r: ResolvedColumn = {
|
|
name: 'embedding',
|
|
type: 'vector',
|
|
dimensions: 1024,
|
|
embeddingModel: 'voyage:voyage-3-large',
|
|
};
|
|
expect(isCacheSafe(r, cfg({ embedding_dimensions: 1536 }))).toBe(false);
|
|
});
|
|
|
|
test('default name BUT overridden to different model (same dim) → unsafe', () => {
|
|
// Different model = different embedding space even at the same dim.
|
|
// OpenAI 1536d vectors are NOT interchangeable with Cohere/Voyage 1536d.
|
|
const r: ResolvedColumn = {
|
|
name: 'embedding',
|
|
type: 'vector',
|
|
dimensions: 1536,
|
|
embeddingModel: 'voyage:voyage-3-large',
|
|
};
|
|
expect(
|
|
isCacheSafe(
|
|
r,
|
|
cfg({
|
|
embedding_dimensions: 1536,
|
|
embedding_model: 'openai:text-embedding-3-large',
|
|
}),
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
test('zero-config brain (cfg has no embedding_dimensions/model) → defaults match → safe', () => {
|
|
const r: ResolvedColumn = {
|
|
name: 'embedding',
|
|
type: 'vector',
|
|
dimensions: 1536,
|
|
embeddingModel: 'openai:text-embedding-3-large',
|
|
};
|
|
expect(isCacheSafe(r, cfg())).toBe(true);
|
|
});
|
|
});
|