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>
228 lines
9.5 KiB
TypeScript
228 lines
9.5 KiB
TypeScript
/**
|
|
* v0.36 E2E (real Postgres) — halfvec + HNSW + doctor SQL probes.
|
|
*
|
|
* Skipped gracefully when DATABASE_URL is unset. When present, exercises
|
|
* the real pgvector + Postgres code paths that PGLite can't fully cover:
|
|
*
|
|
* - halfvec(2560) cast accepted by real pgvector via searchVector.
|
|
* - HNSW index on the alternative column is visible in EXPLAIN.
|
|
* - The format_type SQL the doctor check uses (D13) correctly distinguishes
|
|
* `vector(1024)` vs `vector(1536)` so dim drift can be detected.
|
|
* - The coverage SQL the doctor check uses (D14) computes accurate
|
|
* percentage and the < 90% gate fires when expected.
|
|
*
|
|
* Tests the SQL the doctor check issues, not `runDoctor` itself (which
|
|
* calls process.exit). This is the regression-catching layer that
|
|
* matters; the doctor wrapping just renders the result.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
import { PostgresEngine } from '../../src/core/postgres-engine.ts';
|
|
import { quoteIdentifier } from '../../src/core/search/embedding-column.ts';
|
|
import type { ResolvedColumn } from '../../src/core/types.ts';
|
|
|
|
const dbUrl = process.env.DATABASE_URL;
|
|
if (!dbUrl) {
|
|
describe.skip('postgres E2E — embedding column (skipped: DATABASE_URL unset)', () => {
|
|
test('skipped', () => { expect(true).toBe(true); });
|
|
});
|
|
} else {
|
|
let engine: PostgresEngine;
|
|
let catId: number;
|
|
let dogId: number;
|
|
|
|
beforeAll(async () => {
|
|
engine = new PostgresEngine();
|
|
await engine.connect({ database_url: dbUrl } as never);
|
|
await engine.initSchema();
|
|
|
|
// Wipe state from prior runs.
|
|
await engine.executeRaw(`DELETE FROM content_chunks`);
|
|
await engine.executeRaw(`DELETE FROM pages WHERE slug LIKE 'docs/%'`);
|
|
|
|
// Add the ad-hoc Voyage + ZE columns + HNSW indexes.
|
|
await engine.executeRaw(
|
|
`ALTER TABLE content_chunks ADD COLUMN IF NOT EXISTS embedding_voyage vector(1024)`,
|
|
);
|
|
await engine.executeRaw(
|
|
`ALTER TABLE content_chunks ADD COLUMN IF NOT EXISTS embedding_ze halfvec(2560)`,
|
|
);
|
|
await engine.executeRaw(
|
|
`CREATE INDEX IF NOT EXISTS idx_chunks_embedding_voyage
|
|
ON content_chunks USING hnsw (embedding_voyage vector_cosine_ops)`,
|
|
);
|
|
|
|
// Seed two pages and two chunks.
|
|
await engine.putPage('docs/cat', {
|
|
type: 'concept',
|
|
title: 'Cat doc',
|
|
compiled_truth: 'Cat doc compiled truth.',
|
|
});
|
|
await engine.putPage('docs/dog', {
|
|
type: 'concept',
|
|
title: 'Dog doc',
|
|
compiled_truth: 'Dog doc compiled truth.',
|
|
});
|
|
await engine.upsertChunks('docs/cat', [
|
|
{ chunk_index: 0, chunk_text: 'cat chunk', chunk_source: 'compiled_truth' },
|
|
]);
|
|
await engine.upsertChunks('docs/dog', [
|
|
{ chunk_index: 0, chunk_text: 'dog chunk', chunk_source: 'compiled_truth' },
|
|
]);
|
|
|
|
const idRows = await engine.executeRaw<{ id: number; slug: string }>(
|
|
`SELECT cc.id, p.slug FROM content_chunks cc JOIN pages p ON p.id = cc.page_id WHERE p.slug LIKE 'docs/%' ORDER BY p.slug`,
|
|
);
|
|
catId = idRows.find(r => r.slug === 'docs/cat')!.id;
|
|
dogId = idRows.find(r => r.slug === 'docs/dog')!.id;
|
|
|
|
const vec1024 = (v: number) => `[${new Array(1024).fill(v).join(',')}]`;
|
|
const vec2560 = (v: number) => `[${new Array(2560).fill(v).join(',')}]`;
|
|
await engine.executeRaw(`UPDATE content_chunks SET embedding_voyage = '${vec1024(0.5)}'::vector WHERE id = ${catId}`);
|
|
await engine.executeRaw(`UPDATE content_chunks SET embedding_voyage = '${vec1024(0.7)}'::vector WHERE id = ${dogId}`);
|
|
await engine.executeRaw(`UPDATE content_chunks SET embedding_ze = '${vec2560(0.5)}'::halfvec WHERE id = ${catId}`);
|
|
await engine.executeRaw(`UPDATE content_chunks SET embedding_ze = '${vec2560(0.7)}'::halfvec WHERE id = ${dogId}`);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (engine) await engine.disconnect();
|
|
});
|
|
|
|
describe('Postgres: searchVector with halfvec descriptor', () => {
|
|
test('halfvec(2560) cast accepted; results returned in expected cosine order', async () => {
|
|
const queryVec = new Float32Array(2560).fill(0.5);
|
|
const descriptor: ResolvedColumn = {
|
|
name: 'embedding_ze',
|
|
type: 'halfvec',
|
|
dimensions: 2560,
|
|
embeddingModel: 'zeroentropyai:zembed-1',
|
|
};
|
|
const results = await engine.searchVector(queryVec, {
|
|
embeddingColumn: descriptor,
|
|
limit: 5,
|
|
});
|
|
expect(results.length).toBeGreaterThanOrEqual(1);
|
|
// Cat's halfvec is identical to the query — closer than dog.
|
|
expect(results[0].slug).toBe('docs/cat');
|
|
});
|
|
|
|
test('vector(1024) cast on embedding_voyage routes correctly', async () => {
|
|
const queryVec = new Float32Array(1024).fill(0.5);
|
|
const descriptor: ResolvedColumn = {
|
|
name: 'embedding_voyage',
|
|
type: 'vector',
|
|
dimensions: 1024,
|
|
embeddingModel: 'voyage:voyage-3-large',
|
|
};
|
|
const results = await engine.searchVector(queryVec, {
|
|
embeddingColumn: descriptor,
|
|
limit: 5,
|
|
});
|
|
expect(results.length).toBeGreaterThanOrEqual(1);
|
|
expect(results[0].slug).toBe('docs/cat');
|
|
});
|
|
});
|
|
|
|
describe('Postgres: HNSW index visible to planner', () => {
|
|
test('pg_indexes shows the hnsw index on embedding_voyage', async () => {
|
|
const rows = await engine.executeRaw<{ indexname: string; indexdef: string }>(
|
|
`SELECT indexname, indexdef FROM pg_indexes
|
|
WHERE tablename = 'content_chunks'
|
|
AND schemaname = 'public'`,
|
|
);
|
|
const voyageHnsw = rows.find(r =>
|
|
r.indexname === 'idx_chunks_embedding_voyage' && /USING\s+hnsw/i.test(r.indexdef),
|
|
);
|
|
expect(voyageHnsw).toBeDefined();
|
|
expect(voyageHnsw!.indexdef).toMatch(/embedding_voyage/);
|
|
});
|
|
});
|
|
|
|
describe('Postgres: doctor SQL probes for dim drift (D13)', () => {
|
|
test('format_type returns parenthesized dim — distinguishes vector(1024) from vector(1536)', async () => {
|
|
// This is the exact SQL shape doctor's embedding_column_registry
|
|
// check uses. If pg_attribute or format_type semantics ever change,
|
|
// this test fails loud.
|
|
const rows = await engine.executeRaw<{ attname: string; formatted: string }>(
|
|
`SELECT a.attname, format_type(a.atttypid, a.atttypmod) AS formatted
|
|
FROM pg_attribute a
|
|
JOIN pg_class c ON c.oid = a.attrelid
|
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
WHERE n.nspname = 'public'
|
|
AND c.relname = 'content_chunks'
|
|
AND a.attname = ANY($1::text[])
|
|
AND NOT a.attisdropped`,
|
|
[['embedding', 'embedding_voyage', 'embedding_ze']],
|
|
);
|
|
const byName = new Map<string, string>();
|
|
for (const r of rows) byName.set(r.attname, r.formatted);
|
|
|
|
// Default 'embedding' is vector(1536) by committed schema.
|
|
expect(byName.get('embedding')).toMatch(/^vector\(\d+\)/);
|
|
// Voyage is vector(1024) per the ALTER above.
|
|
expect(byName.get('embedding_voyage')).toBe('vector(1024)');
|
|
// ZE is halfvec(2560) per the ALTER above.
|
|
expect(byName.get('embedding_ze')).toBe('halfvec(2560)');
|
|
});
|
|
|
|
test('format_type catches dim drift: declared 1536 vs actual 1024', async () => {
|
|
// Simulate the user declaring 1536 in their registry but the column
|
|
// is actually 1024d. Doctor's regex-parse of the formatted string
|
|
// is the layer that catches this.
|
|
const rows = await engine.executeRaw<{ formatted: string }>(
|
|
`SELECT format_type(atttypid, atttypmod) AS formatted
|
|
FROM pg_attribute
|
|
WHERE attrelid = 'content_chunks'::regclass
|
|
AND attname = 'embedding_voyage'
|
|
AND NOT attisdropped`,
|
|
);
|
|
const formatted = rows[0]?.formatted ?? '';
|
|
const m = formatted.match(/^(vector|halfvec)\((\d+)\)$/);
|
|
expect(m).toBeTruthy();
|
|
const declaredDims = 1536;
|
|
const actualDims = parseInt(m![2], 10);
|
|
expect(actualDims).not.toBe(declaredDims); // The drift is detectable.
|
|
expect(actualDims).toBe(1024);
|
|
});
|
|
});
|
|
|
|
describe('Postgres: doctor SQL probe for coverage (D14)', () => {
|
|
test('coverage % computed correctly on fully-populated column', async () => {
|
|
const col = quoteIdentifier('embedding_voyage');
|
|
const rows = await engine.executeRaw<{ pct: number; total: number }>(
|
|
`SELECT (
|
|
COUNT(*) FILTER (WHERE ${col} IS NOT NULL)::float
|
|
/ NULLIF(COUNT(*), 0) * 100
|
|
)::float AS pct,
|
|
COUNT(*)::int AS total
|
|
FROM content_chunks`,
|
|
);
|
|
expect(rows[0].total).toBe(2);
|
|
expect(rows[0].pct).toBe(100);
|
|
});
|
|
|
|
test('coverage % drops to 50 after a partial wipe; gate at < 90 fires', async () => {
|
|
// Clear voyage on one chunk to simulate partial backfill.
|
|
await engine.executeRaw(`UPDATE content_chunks SET embedding_voyage = NULL WHERE id = ${dogId}`);
|
|
|
|
const col = quoteIdentifier('embedding_voyage');
|
|
const rows = await engine.executeRaw<{ pct: number; total: number }>(
|
|
`SELECT (
|
|
COUNT(*) FILTER (WHERE ${col} IS NOT NULL)::float
|
|
/ NULLIF(COUNT(*), 0) * 100
|
|
)::float AS pct,
|
|
COUNT(*)::int AS total
|
|
FROM content_chunks`,
|
|
);
|
|
expect(rows[0].total).toBe(2);
|
|
expect(rows[0].pct).toBe(50);
|
|
// The < 90 gate that doctor + config-set use should fire.
|
|
expect(rows[0].pct < 90).toBe(true);
|
|
|
|
// Restore so subsequent tests see the original fixture.
|
|
const v = `[${new Array(1024).fill(0.7).join(',')}]`;
|
|
await engine.executeRaw(`UPDATE content_chunks SET embedding_voyage = '${v}'::vector WHERE id = ${dogId}`);
|
|
});
|
|
});
|
|
}
|