mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
fix(init): error on existing-brain dim mismatch + embedding-migration recipe
Adds A4 hard-error path: when `gbrain init --embedding-dimensions N` is run against an existing brain whose `content_chunks.embedding` column is a different `vector(M)`, init exits 1 with an inline four-step ALTER recipe and a pointer to docs/embedding-migrations.md. This kills the silent-corruption pattern surfaced by issue #673: the v0.27 schema seeded `('embedding_dimensions', '1536')` regardless of the flag, so users got a config saying 768 but a column at 1536 — first sync write blew up with "expected 1536, got 768." A4's contract: 1. Connect to engine BEFORE saveConfig so we can read the live column type 2. If column exists AND dim != requested, exit 1 (loud failure) 3. If column doesn't exist (fresh init) OR dim matches, proceed normally Recipe in docs/embedding-migrations.md (and inlined in init's error output) covers all four destructive steps codex's plan-review caught: 1. DROP INDEX IF EXISTS idx_chunks_embedding (HNSW won't survive ALTER) 2. ALTER TABLE content_chunks ALTER COLUMN embedding TYPE vector(N) 3. UPDATE content_chunks SET embedding = NULL, embedded_at = NULL 4. CREATE INDEX HNSW *only if N <= 2000* (pgvector cap) Step 4 is conditional: dims > 2000 (e.g. Voyage 4 Large 2048d) cannot be HNSW-indexed in pgvector; the recipe explicitly says "Skip reindex" in that case so the user doesn't paste a CREATE INDEX that crashes. Helper `readContentChunksEmbeddingDim` and message builder `embeddingMismatchMessage` live in src/core/embedding-dim-check.ts so doctor 8b (next commit) can reuse the same source of truth. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
8cdd11dfa9
commit
306fc0e1ef
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* v0.28.5 (A4) — Existing-brain dimension-mismatch detection unit tests.
|
||||
*
|
||||
* Pairs with `gbrain init` and `gbrain doctor`'s loud-failure paths. Validates
|
||||
* that:
|
||||
* 1. readContentChunksEmbeddingDim correctly reports null on a fresh brain.
|
||||
* 2. After initSchema, it returns the actual templated dim (1536 default,
|
||||
* or whatever was passed via gateway config).
|
||||
* 3. embeddingMismatchMessage produces a recipe that explicitly drops the
|
||||
* HNSW index, alters the column, wipes embeddings, and conditionally
|
||||
* reindexes — codex's #8 finding from plan review.
|
||||
*/
|
||||
|
||||
import { test, expect, describe } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import {
|
||||
readContentChunksEmbeddingDim,
|
||||
embeddingMismatchMessage,
|
||||
} from '../src/core/embedding-dim-check.ts';
|
||||
|
||||
describe('readContentChunksEmbeddingDim', () => {
|
||||
test('returns { exists: false, dims: null } on a fresh brain', async () => {
|
||||
const engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
try {
|
||||
const result = await readContentChunksEmbeddingDim(engine);
|
||||
expect(result.exists).toBe(false);
|
||||
expect(result.dims).toBeNull();
|
||||
} finally {
|
||||
await engine.disconnect();
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
test('returns dims from a migrated brain (default 1536)', async () => {
|
||||
const engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
try {
|
||||
await engine.initSchema();
|
||||
const result = await readContentChunksEmbeddingDim(engine);
|
||||
expect(result.exists).toBe(true);
|
||||
expect(result.dims).toBe(1536);
|
||||
} finally {
|
||||
await engine.disconnect();
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe('embeddingMismatchMessage', () => {
|
||||
test('inlines all four recipe steps for HNSW-eligible dims', () => {
|
||||
const msg = embeddingMismatchMessage({
|
||||
currentDims: 1536,
|
||||
requestedDims: 768,
|
||||
requestedModel: 'nomic-embed-text',
|
||||
source: 'init',
|
||||
});
|
||||
expect(msg).toContain('vector(1536)');
|
||||
expect(msg).toContain('vector(768)');
|
||||
expect(msg).toContain('DROP INDEX IF EXISTS idx_chunks_embedding');
|
||||
expect(msg).toContain('ALTER TABLE content_chunks ALTER COLUMN embedding TYPE vector(768)');
|
||||
expect(msg).toContain('UPDATE content_chunks SET embedding = NULL');
|
||||
expect(msg).toContain('CREATE INDEX IF NOT EXISTS idx_chunks_embedding');
|
||||
expect(msg).toContain('docs/embedding-migrations.md');
|
||||
});
|
||||
|
||||
test('skips HNSW recreate when requested dims exceed pgvector cap', () => {
|
||||
// Codex finding #8: 2048d (Voyage 4 Large) cannot be HNSW-indexed in pgvector.
|
||||
// The recipe must NOT instruct a CREATE INDEX HNSW for that dim.
|
||||
const msg = embeddingMismatchMessage({
|
||||
currentDims: 1536,
|
||||
requestedDims: 2048,
|
||||
requestedModel: 'voyage-4-large',
|
||||
source: 'init',
|
||||
});
|
||||
expect(msg).toContain('vector(2048)');
|
||||
expect(msg).toContain('Skip reindex');
|
||||
expect(msg).toContain('exceeds pgvector\'s HNSW cap');
|
||||
// The HNSW CREATE INDEX line must NOT appear in the 2048d recipe.
|
||||
expect(msg).not.toContain('CREATE INDEX IF NOT EXISTS idx_chunks_embedding\n ON content_chunks USING hnsw');
|
||||
});
|
||||
|
||||
test('source: doctor uses a different header than source: init', () => {
|
||||
const initMsg = embeddingMismatchMessage({ currentDims: 1536, requestedDims: 768, source: 'init' });
|
||||
const doctorMsg = embeddingMismatchMessage({ currentDims: 1536, requestedDims: 768, source: 'doctor' });
|
||||
expect(initMsg).toContain('Refusing to silently re-template');
|
||||
expect(doctorMsg).toContain('Embedding dimension mismatch detected');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user