mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +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>
177 lines
6.6 KiB
TypeScript
177 lines
6.6 KiB
TypeScript
/**
|
|
* v0.36 (D10) — gateway embed model override path.
|
|
*
|
|
* Pins:
|
|
* - embedQuery(text, { embeddingModel }) routes through THAT provider,
|
|
* not the global default.
|
|
* - embedQuery(text, { dimensions }) flows into dimsProviderOptions
|
|
* so providers that accept output_dimension see the override.
|
|
* - Bare embedQuery(text) continues to use the configured default.
|
|
* - Unknown override model throws (resolveEmbeddingProvider's
|
|
* AIConfigError shape with a hint).
|
|
* - isAvailable('embedding', modelOverride) probes the override's
|
|
* recipe, not the global default's.
|
|
*/
|
|
|
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
import {
|
|
configureGateway,
|
|
embedQuery,
|
|
isAvailable,
|
|
resetGateway,
|
|
__setEmbedTransportForTests,
|
|
} from '../src/core/ai/gateway.ts';
|
|
|
|
interface TransportCall {
|
|
modelString: string;
|
|
values: string[];
|
|
providerOptions: Record<string, unknown>;
|
|
}
|
|
|
|
const calls: TransportCall[] = [];
|
|
|
|
function installCaptureTransport(makeVector: (dims: number) => number[]) {
|
|
__setEmbedTransportForTests(async ({ model, values, providerOptions }: any) => {
|
|
// The AI SDK's `model` object exposes `.modelId` (string) on every
|
|
// openai-compatible model. We capture it so tests can assert the
|
|
// gateway routed to the correct provider:model.
|
|
const modelString = (model?.modelId ?? '<unknown>') as string;
|
|
calls.push({ modelString, values: [...values], providerOptions: { ...(providerOptions ?? {}) } });
|
|
// Pick the dim from providerOpts when present (Voyage flexible-dim
|
|
// path emits openaiCompatible.dimensions); otherwise default 1536.
|
|
const oc = (providerOptions?.openaiCompatible ?? {}) as Record<string, unknown>;
|
|
const dims = typeof oc.dimensions === 'number' ? (oc.dimensions as number) : 1536;
|
|
return {
|
|
embeddings: values.map(() => makeVector(dims)),
|
|
usage: { tokens: 0 },
|
|
} as any;
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
calls.length = 0;
|
|
resetGateway();
|
|
});
|
|
|
|
afterEach(() => {
|
|
__setEmbedTransportForTests(null);
|
|
resetGateway();
|
|
});
|
|
|
|
describe('embedQuery — bare (no opts)', () => {
|
|
test('bare call uses the globally configured embedding_model', async () => {
|
|
configureGateway({
|
|
embedding_model: 'openai:text-embedding-3-large',
|
|
embedding_dimensions: 1536,
|
|
env: { OPENAI_API_KEY: 'sk-test' },
|
|
});
|
|
installCaptureTransport(d => new Array(d).fill(0).map((_, i) => i * 0.001));
|
|
|
|
const v = await embedQuery('hello');
|
|
expect(v.length).toBe(1536);
|
|
expect(calls.length).toBe(1);
|
|
expect(calls[0].modelString).toBe('text-embedding-3-large');
|
|
});
|
|
});
|
|
|
|
describe('embedQuery — { embeddingModel } override', () => {
|
|
test('routes through the override provider, not the global default', async () => {
|
|
configureGateway({
|
|
embedding_model: 'openai:text-embedding-3-large',
|
|
embedding_dimensions: 1536,
|
|
env: { OPENAI_API_KEY: 'sk-test', VOYAGE_API_KEY: 'voy-test' },
|
|
});
|
|
installCaptureTransport(d => new Array(d).fill(0).map((_, i) => i * 0.002));
|
|
|
|
const v = await embedQuery('hello', {
|
|
embeddingModel: 'voyage:voyage-3-large',
|
|
dimensions: 1024,
|
|
});
|
|
expect(v.length).toBe(1024);
|
|
expect(calls.length).toBe(1);
|
|
expect(calls[0].modelString).toBe('voyage-3-large');
|
|
});
|
|
|
|
test('dimensions override flows into providerOptions', async () => {
|
|
configureGateway({
|
|
embedding_model: 'openai:text-embedding-3-large',
|
|
embedding_dimensions: 1536,
|
|
env: { OPENAI_API_KEY: 'sk-test', VOYAGE_API_KEY: 'voy-test' },
|
|
});
|
|
installCaptureTransport(d => new Array(d).fill(0).map(() => 0.1));
|
|
|
|
await embedQuery('hello', { embeddingModel: 'voyage:voyage-3-large', dimensions: 2048 });
|
|
const opts = calls[0].providerOptions as { openaiCompatible?: Record<string, unknown> };
|
|
// Voyage flexible-dim models emit `dimensions` into the openaiCompatible
|
|
// providerOptions block; the shim translates to output_dimension on
|
|
// the wire. Either way, the gateway honored the caller's dim override.
|
|
expect(opts.openaiCompatible).toBeDefined();
|
|
expect(opts.openaiCompatible!.dimensions).toBe(2048);
|
|
});
|
|
|
|
test('unknown override model throws AIConfigError with a useful hint', async () => {
|
|
configureGateway({
|
|
embedding_model: 'openai:text-embedding-3-large',
|
|
embedding_dimensions: 1536,
|
|
env: { OPENAI_API_KEY: 'sk-test' },
|
|
});
|
|
installCaptureTransport(d => new Array(d).fill(0));
|
|
|
|
let threw: Error | null = null;
|
|
try {
|
|
await embedQuery('hello', { embeddingModel: 'nonexistent:bogus' });
|
|
} catch (e) {
|
|
threw = e as Error;
|
|
}
|
|
expect(threw).toBeTruthy();
|
|
// Error message names the provider or model so the user knows what failed.
|
|
expect(threw!.message.toLowerCase()).toMatch(/nonexistent|provider|recipe|model/);
|
|
});
|
|
});
|
|
|
|
describe('isAvailable(touchpoint, modelOverride) — D10', () => {
|
|
test('global default available + Voyage override key present → both available', () => {
|
|
configureGateway({
|
|
embedding_model: 'openai:text-embedding-3-large',
|
|
embedding_dimensions: 1536,
|
|
env: { OPENAI_API_KEY: 'sk-test', VOYAGE_API_KEY: 'voy-test' },
|
|
});
|
|
expect(isAvailable('embedding')).toBe(true);
|
|
expect(isAvailable('embedding', 'voyage:voyage-3-large')).toBe(true);
|
|
});
|
|
|
|
test('global default key missing but override key present → override is available', () => {
|
|
// The single-OPENAI scenario: user removed OPENAI_API_KEY but
|
|
// configured Voyage for the alt column. Pre-D10, isAvailable would
|
|
// have said embedding is unavailable globally and hybridSearch
|
|
// would skip vector search ENTIRELY. With the override, hybrid asks
|
|
// about the active column's provider and gets a green light.
|
|
configureGateway({
|
|
embedding_model: 'openai:text-embedding-3-large',
|
|
embedding_dimensions: 1536,
|
|
env: { VOYAGE_API_KEY: 'voy-test' }, // no OPENAI_API_KEY
|
|
});
|
|
expect(isAvailable('embedding')).toBe(false);
|
|
expect(isAvailable('embedding', 'voyage:voyage-3-large')).toBe(true);
|
|
});
|
|
|
|
test('global default available but override key missing → override is unavailable', () => {
|
|
configureGateway({
|
|
embedding_model: 'openai:text-embedding-3-large',
|
|
embedding_dimensions: 1536,
|
|
env: { OPENAI_API_KEY: 'sk-test' }, // no VOYAGE_API_KEY
|
|
});
|
|
expect(isAvailable('embedding')).toBe(true);
|
|
expect(isAvailable('embedding', 'voyage:voyage-3-large')).toBe(false);
|
|
});
|
|
|
|
test('override against an unknown model returns false', () => {
|
|
configureGateway({
|
|
embedding_model: 'openai:text-embedding-3-large',
|
|
embedding_dimensions: 1536,
|
|
env: { OPENAI_API_KEY: 'sk-test' },
|
|
});
|
|
expect(isAvailable('embedding', 'totally:unknown')).toBe(false);
|
|
});
|
|
});
|