mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 11:22:34 +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>
176 lines
7.0 KiB
TypeScript
176 lines
7.0 KiB
TypeScript
// Phase 4 (F3): loadConfigWithEngine() DB-merge contract.
|
|
//
|
|
// Verifies precedence (env > file > DB > defaults) for the new v0.27.1
|
|
// multimodal flags so `gbrain config set embedding_multimodal true`
|
|
// actually flips the runtime gate even when the file plane is silent.
|
|
|
|
import { describe, expect, test } from 'bun:test';
|
|
import { loadConfigWithEngine, type GBrainConfig } from '../src/core/config.ts';
|
|
|
|
interface FakeEngine {
|
|
getConfig(key: string): Promise<string | null | undefined>;
|
|
}
|
|
|
|
function makeEngine(map: Record<string, string | null | undefined>): FakeEngine {
|
|
return {
|
|
async getConfig(key: string) {
|
|
return map[key];
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('loadConfigWithEngine (Phase 4 / F3)', () => {
|
|
test('synthesizes a minimal base when base config is null (v0.36 codex /ship #3)', async () => {
|
|
// Pre-v0.36 this returned null and skipped DB-plane merge entirely.
|
|
// That meant env-only Postgres installs (no file config) couldn't see
|
|
// DB-plane overrides set via `gbrain config set` — the documented
|
|
// smoke test for `search_embedding_column` would silently fail.
|
|
// The fix synthesizes a minimal `{ engine: 'postgres' }` base so DB
|
|
// merge still runs; downstream callers either find the DB key or
|
|
// fall through to defaults.
|
|
const result = await loadConfigWithEngine(makeEngine({}), null);
|
|
expect(result).not.toBeNull();
|
|
expect(result?.engine).toBe('postgres');
|
|
});
|
|
|
|
test('DB-plane embedding_columns merge works even with null base (codex /ship #3 round-trip)', async () => {
|
|
// The whole point of the synthesized fallback: env-only installs
|
|
// calling `gbrain config set embedding_columns '...'` get those keys
|
|
// back when the resolver re-reads config. Verifies the merge path
|
|
// actually runs (not just that the function returns truthy).
|
|
const engine = makeEngine({
|
|
search_embedding_column: 'embedding_voyage',
|
|
embedding_columns: '{"embedding_voyage":{"provider":"voyage:voyage-3-large","dimensions":1024,"type":"vector"}}',
|
|
});
|
|
const merged = await loadConfigWithEngine(engine, null);
|
|
expect(merged?.search_embedding_column).toBe('embedding_voyage');
|
|
expect(merged?.embedding_columns?.embedding_voyage?.dimensions).toBe(1024);
|
|
});
|
|
|
|
test('DB flag fills in when file/env did not set it', async () => {
|
|
const base: GBrainConfig = { engine: 'pglite' };
|
|
const engine = makeEngine({
|
|
embedding_multimodal: 'true',
|
|
embedding_image_ocr: 'false',
|
|
embedding_image_ocr_model: 'openai:gpt-4o-mini',
|
|
});
|
|
const merged = await loadConfigWithEngine(engine, base);
|
|
expect(merged?.embedding_multimodal).toBe(true);
|
|
expect(merged?.embedding_image_ocr).toBe(false);
|
|
expect(merged?.embedding_image_ocr_model).toBe('openai:gpt-4o-mini');
|
|
});
|
|
|
|
test('file/env precedence: file value wins over DB value', async () => {
|
|
const base: GBrainConfig = {
|
|
engine: 'pglite',
|
|
embedding_multimodal: false,
|
|
embedding_image_ocr_model: 'file-set-model',
|
|
};
|
|
const engine = makeEngine({
|
|
embedding_multimodal: 'true',
|
|
embedding_image_ocr_model: 'db-set-model',
|
|
});
|
|
const merged = await loadConfigWithEngine(engine, base);
|
|
expect(merged?.embedding_multimodal).toBe(false);
|
|
expect(merged?.embedding_image_ocr_model).toBe('file-set-model');
|
|
});
|
|
|
|
test('partial DB merge: only undefined fields fall through', async () => {
|
|
const base: GBrainConfig = {
|
|
engine: 'pglite',
|
|
embedding_multimodal: true,
|
|
// embedding_image_ocr NOT set in file plane
|
|
};
|
|
const engine = makeEngine({
|
|
embedding_multimodal: 'false',
|
|
embedding_image_ocr: 'true',
|
|
});
|
|
const merged = await loadConfigWithEngine(engine, base);
|
|
// file/env wins for multimodal
|
|
expect(merged?.embedding_multimodal).toBe(true);
|
|
// DB fills in for ocr
|
|
expect(merged?.embedding_image_ocr).toBe(true);
|
|
});
|
|
|
|
test('engine.getConfig throwing is non-fatal — file/env config still returned', async () => {
|
|
const base: GBrainConfig = {
|
|
engine: 'pglite',
|
|
embedding_multimodal: true,
|
|
};
|
|
const engine: FakeEngine = {
|
|
async getConfig() {
|
|
throw new Error('config table missing');
|
|
},
|
|
};
|
|
const merged = await loadConfigWithEngine(engine, base);
|
|
expect(merged?.embedding_multimodal).toBe(true);
|
|
});
|
|
|
|
test('null/empty DB values are ignored (not coerced to false)', async () => {
|
|
const base: GBrainConfig = { engine: 'pglite' };
|
|
const engine = makeEngine({
|
|
embedding_multimodal: null,
|
|
embedding_image_ocr: '',
|
|
embedding_image_ocr_model: undefined,
|
|
});
|
|
const merged = await loadConfigWithEngine(engine, base);
|
|
expect(merged?.embedding_multimodal).toBeUndefined();
|
|
expect(merged?.embedding_image_ocr).toBeUndefined();
|
|
expect(merged?.embedding_image_ocr_model).toBeUndefined();
|
|
});
|
|
|
|
test('non-"true" DB string values resolve to false (strict equality)', async () => {
|
|
const base: GBrainConfig = { engine: 'pglite' };
|
|
const engine = makeEngine({
|
|
embedding_multimodal: 'TRUE', // wrong case
|
|
embedding_image_ocr: '1', // wrong format
|
|
});
|
|
const merged = await loadConfigWithEngine(engine, base);
|
|
expect(merged?.embedding_multimodal).toBe(false);
|
|
expect(merged?.embedding_image_ocr).toBe(false);
|
|
});
|
|
|
|
// v0.28.11 (PR #719): embedding_multimodal_model precedence parity with the
|
|
// sibling embedding_image_ocr_model field. Confirms the new key participates
|
|
// in the same env > file > DB > undefined merge contract so that
|
|
// embedMultimodal() routes correctly regardless of which plane set it.
|
|
describe('embedding_multimodal_model precedence', () => {
|
|
test('DB value fills in when file/env did not set it', async () => {
|
|
const base: GBrainConfig = { engine: 'pglite' };
|
|
const engine = makeEngine({
|
|
embedding_multimodal_model: 'voyage:voyage-multimodal-3',
|
|
});
|
|
const merged = await loadConfigWithEngine(engine, base);
|
|
expect(merged?.embedding_multimodal_model).toBe('voyage:voyage-multimodal-3');
|
|
});
|
|
|
|
test('file value wins over DB value', async () => {
|
|
const base: GBrainConfig = {
|
|
engine: 'pglite',
|
|
embedding_multimodal_model: 'voyage:voyage-multimodal-3',
|
|
};
|
|
const engine = makeEngine({
|
|
embedding_multimodal_model: 'voyage:voyage-3-large',
|
|
});
|
|
const merged = await loadConfigWithEngine(engine, base);
|
|
expect(merged?.embedding_multimodal_model).toBe('voyage:voyage-multimodal-3');
|
|
});
|
|
|
|
test('all unset stays undefined', async () => {
|
|
const base: GBrainConfig = { engine: 'pglite' };
|
|
const engine = makeEngine({});
|
|
const merged = await loadConfigWithEngine(engine, base);
|
|
expect(merged?.embedding_multimodal_model).toBeUndefined();
|
|
});
|
|
|
|
test('null/empty DB string is ignored (does not clobber)', async () => {
|
|
const base: GBrainConfig = { engine: 'pglite' };
|
|
const engine = makeEngine({
|
|
embedding_multimodal_model: '',
|
|
});
|
|
const merged = await loadConfigWithEngine(engine, base);
|
|
expect(merged?.embedding_multimodal_model).toBeUndefined();
|
|
});
|
|
});
|
|
});
|