Files
gbrain/test/migrate-embeddings-boundary.serial.test.ts
T
0bbaed2e48 v0.42.67.0 feat(migrate): provider-agnostic embedding migration — the path off ZeroEntropy (#3390, fixes #3391) (#3459)
* 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>
2026-07-27 17:48:37 -07:00

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);
});