mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-29 17:45:16 +00:00
* feat(migrate): provider-agnostic embedding migration service — the path off ZeroEntropy (#3390) - gbrain migrate embeddings --to <provider:model> (alias: retrieval-upgrade): plan + cost preflight, consent gate (--yes / TTY confirm / non-TTY exit 2), live probe against the target provider before any mutation, env-override gate, schema dimension transition via the shared runSchemaTransition, dual-plane config write, NULL-signature-inclusive invalidation, query-cache purge, resumable re-embed through the standard embed pipeline (single-flight locks, backoff, pacing, stderr progress). Killed runs resume by re-running the same command; the NULL-embedding column is the checkpoint. - #3391 root-cause fix (both engines): countStaleChunks / sumStaleChunkChars / invalidateStaleSignatureEmbeddings accept includeNullSignature to lift the v108 grandfather clause; embed --stale warns loudly when a model swap leaves NULL-signature pages in the old embedding space, and --include-null-signature re-embeds them. Default sweep behavior unchanged. - knobs_hash v=12 → v=13 (prov=default legacy callers must not be served pre-migration cache rows). - migrate_embeddings op: scope admin, localOnly, hidden cliHints, hard remote refusal, needs_confirmation without yes=true. - One-shot post-upgrade ZE-sunset banner (ze_sunset_notice_shown) for brains resolving to a zeroentropyai:* embedding model or reranker. - doctor's dimension-mismatch repair hint now names the real command. - Docs: docs/guides/embedding-migration.md, KEY_FILES entries, spend-controls gate row. Tests: PGLite unit + full-lifecycle flow (interrupted-run resume), real-Postgres e2e (pgvector DDL path + #3391 predicate parity). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): satisfy check:test-isolation + bump the remaining knobs_hash pins - test/migrate-embeddings-flow.test.ts → .serial.test.ts: the file holds a temp GBRAIN_HOME + an installed fake embed transport for its whole lifecycle (beforeAll → afterAll), which withEnv() can't wrap. This also fixes the CI shard-pollution failure in test/ai/recipes-existing-regression.test.ts (that file passes solo on both master and this branch; the flow test's configureGateway + provider-key deletion was leaking into it inside the same shard process). - test/embedding-migration.test.ts: env-override case now uses withEnv(). - Bump the three remaining KNOBS_HASH_VERSION pins to 13 (cross-modal-phase1, search-alias-resolved-boost, search/knobs-hash-reranker). - Docs + llms bundles follow the test rename. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(test): wire the new Postgres e2e into the smart e2e selector map Changes to embed.ts / embedding-migration.ts / retrieval-upgrade-planner.ts / postgres-engine.ts now trigger test/e2e/migrate-embeddings-postgres.test.ts — the #3391 stale predicates and runSchemaTransition's DDL path behave differently on real pgvector than on PGLite, so the smart selector has to know. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(migrate): consult spend.posture in the embedding-migration consent gate The brief asked the gate to honor spend.posture; it previously didn't read it at all. Now it does — but deliberately does NOT bypass on tokenmax: posture waives the spend CEILING, and this gate also guards a destructive schema rebuild (existing vectors dropped, retrieval degraded until the re-embed finishes). Under tokenmax the dollar figure is marked informational on stderr and the confirmation is still asked; --yes stays the single scripted bypass. Pinned by a new case in the flow test so a later refactor can't quietly turn posture into a bypass. Guide + spend-controls table updated to match. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * wip: blocker fixes --------- Co-authored-by: Garry Tan <garrytan@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
282 lines
12 KiB
TypeScript
282 lines
12 KiB
TypeScript
/**
|
|
* #3390 — `gbrain migrate embeddings` END-TO-END on PGLite.
|
|
*
|
|
* The full command flow with a fake embedding transport:
|
|
* 1. Disposable brain seeded via the REAL embed pipeline on a fake 1280d
|
|
* "zeroentropyai:zembed-1" provider (the shipped default).
|
|
* 2. One page's embedding_signature NULLed (simulates a pre-v108 page,
|
|
* the #3391 class).
|
|
* 3. Migration to a fake 1536d openai:text-embedding-3-small — with the
|
|
* transport failing two specific pages, simulating a killed/partial
|
|
* run. Asserts: exit 1 (incomplete), schema at 1536, config file
|
|
* swapped, state marker kept, partial progress banked.
|
|
* 4. Re-run of the SAME command (the documented resume path). Asserts:
|
|
* exit 0, only the two failed pages re-embedded (no duplicate work),
|
|
* every chunk embedded at 1536, every page stamped with the new
|
|
* signature (including the formerly-NULL one), state marker cleared,
|
|
* query cache purged, vector search works against the new column.
|
|
*
|
|
* Named `.serial.test.ts`: the whole file runs under a temp GBRAIN_HOME +
|
|
* an installed fake embed transport for its entire lifecycle (beforeAll →
|
|
* afterAll), which withEnv() cannot wrap. GBRAIN_HOME is pointed at a temp dir so the file-plane config write
|
|
* (persistEmbeddingFileConfig) never touches the developer's ~/.gbrain.
|
|
*/
|
|
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import {
|
|
configureGateway,
|
|
resetGateway,
|
|
__setEmbedTransportForTests,
|
|
} from '../src/core/ai/gateway.ts';
|
|
import { runEmbedCore } from '../src/commands/embed.ts';
|
|
import { runMigrateEmbeddings } from '../src/commands/migrate-embeddings.ts';
|
|
import {
|
|
MIGRATION_STATE_KEY,
|
|
MIGRATION_COMPLETED_KEY,
|
|
} from '../src/core/embedding-migration.ts';
|
|
|
|
const FROM_DIMS = 1280;
|
|
const TO_DIMS = 1536;
|
|
const PAGES = ['page-1', 'page-2', 'page-3', 'page-4', 'page-5', 'page-6'];
|
|
const PROBE_TEXT = 'gbrain embedding migration probe';
|
|
|
|
let engine: PGLiteEngine;
|
|
let tmpHome: string;
|
|
const savedEnv: Record<string, string | undefined> = {};
|
|
|
|
/** Deterministic fake transport: vector width driven by the test phase. */
|
|
let currentDims = FROM_DIMS;
|
|
/** Texts that make the transport throw (simulates a mid-run kill). */
|
|
let failTexts: string[] = [];
|
|
/** Every non-probe text the transport embedded, per phase. */
|
|
let embeddedTexts: string[] = [];
|
|
|
|
function installTransport(): void {
|
|
__setEmbedTransportForTests(async ({ values }: { values: string[] }) => {
|
|
for (const v of values) {
|
|
if (failTexts.some(f => v.includes(f))) {
|
|
throw new Error(`fake transport: simulated failure for "${v.slice(0, 30)}..."`);
|
|
}
|
|
}
|
|
for (const v of values) {
|
|
if (v !== PROBE_TEXT) embeddedTexts.push(v);
|
|
}
|
|
return {
|
|
embeddings: values.map(() => new Array(currentDims).fill(0).map((_, i) => Math.sin(i) * 0.01 + 0.001)),
|
|
usage: { tokens: values.length * 4 },
|
|
} as never;
|
|
});
|
|
}
|
|
|
|
class ExitError extends Error {
|
|
constructor(public code: number) { super(`exit ${code}`); }
|
|
}
|
|
const exitSeam = (code: number): never => { throw new ExitError(code); };
|
|
|
|
async function runMigrate(args: string[]): Promise<number> {
|
|
try {
|
|
await runMigrateEmbeddings(engine, args, { exit: exitSeam });
|
|
throw new Error('runMigrateEmbeddings returned without exiting');
|
|
} catch (e) {
|
|
if (e instanceof ExitError) return e.code;
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
async function columnDims(): Promise<number> {
|
|
const rows = await engine.executeRaw<{ dim: number }>(
|
|
`SELECT atttypmod AS dim FROM pg_attribute
|
|
WHERE attrelid = 'content_chunks'::regclass AND attname = 'embedding'
|
|
AND attnum > 0 AND NOT attisdropped`,
|
|
);
|
|
return Number(rows[0]?.dim);
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
// Isolate the file-plane config.
|
|
for (const k of ['GBRAIN_HOME', 'GBRAIN_EMBEDDING_MODEL', 'GBRAIN_EMBEDDING_DIMENSIONS', 'OPENAI_API_KEY', 'ZEROENTROPY_API_KEY', 'DATABASE_URL']) {
|
|
savedEnv[k] = process.env[k];
|
|
delete process.env[k];
|
|
}
|
|
tmpHome = mkdtempSync(join(tmpdir(), 'gbrain-migrate-e2e-'));
|
|
process.env.GBRAIN_HOME = tmpHome;
|
|
mkdirSync(join(tmpHome, '.gbrain'), { recursive: true });
|
|
writeFileSync(join(tmpHome, '.gbrain', 'config.json'), JSON.stringify({
|
|
engine: 'pglite',
|
|
embedding_model: 'zeroentropyai:zembed-1',
|
|
embedding_dimensions: FROM_DIMS,
|
|
zeroentropy_api_key: 'ze-test-fake',
|
|
openai_api_key: 'sk-test-fake',
|
|
}, null, 2));
|
|
|
|
resetGateway();
|
|
configureGateway({
|
|
embedding_model: 'zeroentropyai:zembed-1',
|
|
embedding_dimensions: FROM_DIMS,
|
|
env: { ZEROENTROPY_API_KEY: 'ze-test-fake', OPENAI_API_KEY: 'sk-test-fake' },
|
|
});
|
|
installTransport();
|
|
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({ embedding_dimensions: FROM_DIMS } as never);
|
|
await engine.initSchema();
|
|
}, 60000);
|
|
|
|
afterAll(async () => {
|
|
__setEmbedTransportForTests(null);
|
|
resetGateway();
|
|
await engine.disconnect();
|
|
rmSync(tmpHome, { recursive: true, force: true });
|
|
for (const [k, v] of Object.entries(savedEnv)) {
|
|
if (v === undefined) delete process.env[k];
|
|
else process.env[k] = v;
|
|
}
|
|
});
|
|
|
|
describe('migrate embeddings — full flow on PGLite', () => {
|
|
test('seed: 6 pages embedded at 1280d through the real embed pipeline', async () => {
|
|
expect(await columnDims()).toBe(FROM_DIMS);
|
|
for (const slug of PAGES) {
|
|
await engine.putPage(slug, { type: 'note', title: slug, compiled_truth: `# ${slug}\n\ncontent for ${slug}` });
|
|
await engine.upsertChunks(slug, [
|
|
{ chunk_index: 0, chunk_text: `chunk text for ${slug}`, chunk_source: 'compiled_truth', token_count: 5 },
|
|
]);
|
|
}
|
|
const seeded = await runEmbedCore(engine, { stale: true, quiet: true });
|
|
expect(seeded.embedded).toBe(PAGES.length);
|
|
expect(await engine.countStaleChunks()).toBe(0);
|
|
|
|
// Simulate a pre-v108 page: embedded, but no recorded signature (#3391).
|
|
await engine.executeRaw(`UPDATE pages SET embedding_signature = NULL WHERE slug = 'page-1'`);
|
|
const sigs = await engine.executeRaw<{ n: number }>(
|
|
`SELECT count(*)::int AS n FROM pages WHERE embedding_signature = 'zeroentropyai:zembed-1:${FROM_DIMS}'`,
|
|
);
|
|
expect(Number(sigs[0]?.n)).toBe(PAGES.length - 1);
|
|
|
|
// Seed a query-cache row that must not survive the migration.
|
|
await engine.executeRaw(
|
|
`INSERT INTO query_cache (id, query_text, source_id) VALUES ('qc-pre', 'old space query', 'default')`,
|
|
);
|
|
}, 60000);
|
|
|
|
test('dry-run: prints the plan, changes nothing', async () => {
|
|
const code = await runMigrate(['--to', 'openai:text-embedding-3-small', '--dry-run', '--json']);
|
|
expect(code).toBe(0);
|
|
expect(await columnDims()).toBe(FROM_DIMS);
|
|
expect(await engine.getConfig(MIGRATION_STATE_KEY)).toBeFalsy();
|
|
// Config file untouched.
|
|
const cfg = JSON.parse(readFileSync(join(tmpHome, '.gbrain', 'config.json'), 'utf-8'));
|
|
expect(cfg.embedding_model).toBe('zeroentropyai:zembed-1');
|
|
});
|
|
|
|
test('non-TTY without --yes refuses with exit 2 (cost gate)', async () => {
|
|
const code = await runMigrate(['--to', 'openai:text-embedding-3-small']);
|
|
expect(code).toBe(2);
|
|
expect(await columnDims()).toBe(FROM_DIMS);
|
|
});
|
|
|
|
test('spend.posture=tokenmax does NOT bypass the gate (guards a destructive rebuild, not just spend)', async () => {
|
|
await engine.setConfig('spend.posture', 'tokenmax');
|
|
try {
|
|
const code = await runMigrate(['--to', 'openai:text-embedding-3-small']);
|
|
expect(code).toBe(2); // posture waives the spend ceiling, not the consent
|
|
expect(await columnDims()).toBe(FROM_DIMS);
|
|
} finally {
|
|
await engine.unsetConfig('spend.posture');
|
|
}
|
|
});
|
|
|
|
test('interrupted run: partial progress banks, exit 1, state marker kept', async () => {
|
|
currentDims = TO_DIMS;
|
|
failTexts = ['page-4', 'page-5']; // simulate dying mid-run on two pages
|
|
embeddedTexts = [];
|
|
|
|
const code = await runMigrate(['--to', 'openai:text-embedding-3-small', '--yes']);
|
|
expect(code).toBe(1); // incomplete
|
|
|
|
// Schema + config swapped BEFORE the re-embed, so the partial run is
|
|
// already in the new space.
|
|
expect(await columnDims()).toBe(TO_DIMS);
|
|
const cfg = JSON.parse(readFileSync(join(tmpHome, '.gbrain', 'config.json'), 'utf-8'));
|
|
expect(cfg.embedding_model).toBe('openai:text-embedding-3-small');
|
|
expect(cfg.embedding_dimensions).toBe(TO_DIMS);
|
|
expect(await engine.getConfig('embedding_model')).toBe('openai:text-embedding-3-small');
|
|
|
|
// 4 of 6 pages embedded; the 2 failed pages remain stale (= the checkpoint).
|
|
expect(embeddedTexts.length).toBe(PAGES.length - 2);
|
|
expect(await engine.countStaleChunks()).toBe(2);
|
|
|
|
// In-flight marker survives so doctor/status can see the migration.
|
|
expect(await engine.getConfig(MIGRATION_STATE_KEY)).toBeTruthy();
|
|
expect(await engine.getConfig(MIGRATION_COMPLETED_KEY)).toBeFalsy();
|
|
|
|
// Query cache was purged at swap time.
|
|
const qc = await engine.executeRaw<{ n: number }>(`SELECT count(*)::int AS n FROM query_cache`);
|
|
expect(Number(qc[0]?.n)).toBe(0);
|
|
}, 60000);
|
|
|
|
test('resume: re-running the same command finishes without redoing work', async () => {
|
|
failTexts = [];
|
|
embeddedTexts = [];
|
|
|
|
const code = await runMigrate(['--to', 'openai:text-embedding-3-small', '--yes']);
|
|
expect(code).toBe(0);
|
|
|
|
// Only the two previously-failed pages were embedded this pass.
|
|
expect(embeddedTexts.length).toBe(2);
|
|
expect(embeddedTexts.join(' ')).toContain('page-4');
|
|
expect(embeddedTexts.join(' ')).toContain('page-5');
|
|
|
|
// Everything is in the target space now.
|
|
expect(await engine.countStaleChunks()).toBe(0);
|
|
expect(await engine.countStaleChunks({
|
|
signature: `openai:text-embedding-3-small:${TO_DIMS}`,
|
|
includeNullSignature: true,
|
|
})).toBe(0);
|
|
|
|
// Every page — including the formerly NULL-signature page-1 — is stamped.
|
|
const sigs = await engine.executeRaw<{ n: number }>(
|
|
`SELECT count(*)::int AS n FROM pages
|
|
WHERE embedding_signature = 'openai:text-embedding-3-small:${TO_DIMS}'`,
|
|
);
|
|
expect(Number(sigs[0]?.n)).toBe(PAGES.length);
|
|
|
|
// Bookkeeping: marker cleared, completion stamped.
|
|
expect(await engine.getConfig(MIGRATION_STATE_KEY)).toBeFalsy();
|
|
const done = JSON.parse((await engine.getConfig(MIGRATION_COMPLETED_KEY))!);
|
|
expect(done.to_model).toBe('openai:text-embedding-3-small');
|
|
}, 60000);
|
|
|
|
test('post-migration: vector search works against the new 1536d column', async () => {
|
|
// HNSW index was rebuilt by the schema transition.
|
|
const idx = await engine.executeRaw<{ indexname: string }>(
|
|
`SELECT indexname FROM pg_indexes WHERE tablename = 'content_chunks' AND indexname = 'idx_chunks_embedding'`,
|
|
);
|
|
expect(idx.length).toBe(1);
|
|
|
|
// A cosine query at the new width returns results (all 6 chunks embedded).
|
|
const qvec = `[${new Array(TO_DIMS).fill(0).map((_, i) => (Math.sin(i) * 0.01 + 0.001).toFixed(6)).join(',')}]`;
|
|
const rows = await engine.executeRaw<{ slug: string }>(
|
|
`SELECT p.slug FROM content_chunks cc
|
|
JOIN pages p ON p.id = cc.page_id
|
|
WHERE cc.embedding IS NOT NULL
|
|
ORDER BY cc.embedding <=> $1::vector
|
|
LIMIT 3`,
|
|
[qvec],
|
|
);
|
|
expect(rows.length).toBe(3);
|
|
});
|
|
|
|
test('re-run on an already-migrated brain is a clean no-op', async () => {
|
|
embeddedTexts = [];
|
|
const code = await runMigrate(['--to', 'openai:text-embedding-3-small', '--yes']);
|
|
expect(code).toBe(0);
|
|
// Nothing re-embedded (probe excluded from embeddedTexts by design).
|
|
expect(embeddedTexts.length).toBe(0);
|
|
expect(await columnDims()).toBe(TO_DIMS);
|
|
});
|
|
});
|