mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +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>
166 lines
6.5 KiB
TypeScript
166 lines
6.5 KiB
TypeScript
/**
|
|
* #3390 regression: page straddling a stale-batch BOUNDARY (adversarial review
|
|
* blocker 2).
|
|
*
|
|
* The embed loop stamps `pages.embedding_signature` only when
|
|
* `stale.length === existing.length` — i.e. when every chunk of the page landed
|
|
* in the SAME batch. `listStaleChunks` is a plain keyset LIMIT with no page
|
|
* alignment, so on any corpus bigger than one batch the page split across the
|
|
* boundary is embedded correctly but never stamped.
|
|
*
|
|
* Pre-fix consequence: the completion probe counted that page stale, the
|
|
* command printed "Migration incomplete" and exited 1 on a perfectly-migrated
|
|
* brain, and the re-run RE-INVALIDATED and RE-PAID for those pages —
|
|
* contradicting the "already-migrated chunks are never re-embedded" contract.
|
|
*
|
|
* Shape here: 3 pages x 2 chunks = 6 chunks with --batch-size 3, so the
|
|
* boundary falls mid-page-2. Asserts exit 0 on the first run, every page
|
|
* stamped, and ZERO embed work on the second run.
|
|
*
|
|
* Named `.serial.test.ts`: holds a temp GBRAIN_HOME + an installed fake embed
|
|
* transport for its whole beforeAll→afterAll lifecycle, which withEnv() can't
|
|
* wrap.
|
|
*/
|
|
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
import { mkdtempSync, mkdirSync, writeFileSync, 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 = ['b-1', 'b-2', 'b-3'];
|
|
const PROBE_TEXT = 'gbrain embedding migration probe';
|
|
|
|
let engine: PGLiteEngine;
|
|
let tmpHome: string;
|
|
const savedEnv: Record<string, string | undefined> = {};
|
|
let currentDims = FROM_DIMS;
|
|
let embeddedTexts: string[] = [];
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
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-boundary-'));
|
|
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' },
|
|
});
|
|
__setEmbedTransportForTests(async ({ values }: { values: string[] }) => {
|
|
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.003)),
|
|
usage: { tokens: values.length * 4 },
|
|
} as never;
|
|
});
|
|
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({ embedding_dimensions: FROM_DIMS } as never);
|
|
await engine.initSchema();
|
|
|
|
// 3 pages x 2 chunks each = 6 chunks.
|
|
for (const slug of PAGES) {
|
|
await engine.putPage(slug, { type: 'note', title: slug, compiled_truth: `# ${slug}` });
|
|
await engine.upsertChunks(slug, [
|
|
{ chunk_index: 0, chunk_text: `${slug} chunk zero`, chunk_source: 'compiled_truth', token_count: 4 },
|
|
{ chunk_index: 1, chunk_text: `${slug} chunk one`, chunk_source: 'compiled_truth', token_count: 4 },
|
|
]);
|
|
}
|
|
await runEmbedCore(engine, { stale: true, quiet: true });
|
|
}, 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('migration across a stale-batch boundary', () => {
|
|
test('seed is fully embedded at the source width', async () => {
|
|
expect(await engine.countStaleChunks()).toBe(0);
|
|
const n = await engine.executeRaw<{ n: number }>(
|
|
`SELECT count(*)::int AS n FROM content_chunks WHERE embedding IS NOT NULL`,
|
|
);
|
|
expect(Number(n[0]?.n)).toBe(6);
|
|
});
|
|
|
|
test('batch-size 3 splits a page across the boundary yet still exits 0 with every page stamped', async () => {
|
|
currentDims = TO_DIMS;
|
|
embeddedTexts = [];
|
|
|
|
const code = await runMigrate([
|
|
'--to', 'openai:text-embedding-3-small', '--yes', '--batch-size', '3',
|
|
]);
|
|
// Pre-fix this was 1 ("Migration incomplete") even though all 6 chunks
|
|
// were correctly embedded — the boundary page was never stamped.
|
|
expect(code).toBe(0);
|
|
|
|
expect(embeddedTexts.length).toBe(6); // all six chunks re-embedded once
|
|
expect(await engine.countStaleChunks()).toBe(0);
|
|
|
|
// Every page carries the TARGET signature, including the boundary page.
|
|
const sig = `openai:text-embedding-3-small:${TO_DIMS}`;
|
|
const stamped = await engine.executeRaw<{ n: number }>(
|
|
`SELECT count(*)::int AS n FROM pages WHERE embedding_signature = $1`,
|
|
[sig],
|
|
);
|
|
expect(Number(stamped[0]?.n)).toBe(PAGES.length);
|
|
|
|
// Completion bookkeeping ran (it only runs when the backlog drained).
|
|
expect(await engine.getConfig(MIGRATION_STATE_KEY)).toBeFalsy();
|
|
expect(await engine.getConfig(MIGRATION_COMPLETED_KEY)).toBeTruthy();
|
|
}, 60000);
|
|
|
|
test('second run does ZERO work — the "never re-embedded twice" contract holds across a boundary', async () => {
|
|
embeddedTexts = [];
|
|
const code = await runMigrate([
|
|
'--to', 'openai:text-embedding-3-small', '--yes', '--batch-size', '3',
|
|
]);
|
|
expect(code).toBe(0);
|
|
// Pre-fix the unstamped boundary page was re-invalidated and PAID FOR again.
|
|
expect(embeddedTexts.length).toBe(0);
|
|
}, 60000);
|
|
});
|