Files
gbrain/src/core/embedding-migration.ts
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

341 lines
14 KiB
TypeScript

/**
* Provider-agnostic embedding migration (#3390).
*
* `gbrain migrate embeddings --to <provider:model>` re-embeds a brain onto
* any configured provider — the forward path off a sunsetting provider that
* `ze-switch` (ZE-only target) and `ze-switch --undo` (needs a snapshot fresh
* installs don't have) cannot cover.
*
* Deliberately thin: everything heavy is reused —
* - runSchemaTransition (retrieval-upgrade-planner.ts) for dimension changes
* - invalidateStaleSignatureEmbeddings + the NULL-embedding cursor for
* staleness + resume (the NULL column IS the checkpoint: a killed run
* re-runs the same command and continues where it stopped)
* - the embed pipeline (src/commands/embed.ts) for the actual re-embed,
* with pacing, backfill locks, rate-limit backoff, and progress
* - lookupEmbeddingPrice / estimateCostFromChars for the preflight estimate
* - detectEnvOverride (the #1421 damage-class gate) before any mutation
*
* #3391 companion fix: the migration widens staleness with
* `includeNullSignature: true` so pages that predate the v108 signature stamp
* are re-embedded too, instead of silently staying in the old embedding space.
*
* The command layer (src/commands/migrate-embeddings.ts) owns everything
* process-shaped: confirm prompts, file-plane config persistence (the gateway
* reads file/env, not the DB plane), gateway reconfiguration, and the embed
* catch-up run. This module is engine-pure so both engines and the op handler
* share one implementation.
*/
import type { BrainEngine } from './engine.ts';
import { resolveRecipe, embeddingDimsForModel } from './ai/model-resolver.ts';
import { lookupEmbeddingPrice, estimateCostFromChars } from './embedding-pricing.ts';
import { detectEnvOverride, type EnvOverrideWarning } from './retrieval-upgrade-planner.ts';
import { runSchemaTransition } from './retrieval-upgrade-planner.ts';
import { readContentChunksEmbeddingDim } from './embedding-dim-check.ts';
import { DEFAULT_EMBEDDING_MODEL, DEFAULT_EMBEDDING_DIMENSIONS } from './ai/defaults.ts';
/**
* Resume/state marker (DB plane). Present while a migration is in flight so
* a re-run can detect + resume; cleared when the re-embed drains to zero.
*/
export const MIGRATION_STATE_KEY = 'embedding_migration.state';
/** ISO timestamp + summary of the last completed migration (DB plane). */
export const MIGRATION_COMPLETED_KEY = 'embedding_migration.completed';
export interface MigrationState {
to_model: string;
to_dims: number;
from_model: string;
from_dims: number;
started_at: string;
}
export interface EmbeddingMigrationPlan {
from_model: string;
from_dims: number;
/** Actual `content_chunks.embedding` vector(N) width (null = column absent). */
column_dims: number | null;
to_model: string;
to_dims: number;
/** True when the schema column must be rebuilt at a new width. */
dim_change: boolean;
/** Chunks not yet in the target embedding space (the migration workload). */
chunks_to_embed: number;
/** Characters across those chunks (feeds the cost estimate). */
total_chars: number;
/**
* #3391 visibility: embedded chunks on pages with NO recorded signature
* (pre-v108). Included in chunks_to_embed via includeNullSignature.
*/
null_signature_chunks: number;
est_cost_usd: number;
/** False when the target model has no entry in EMBEDDING_PRICING. */
price_known: boolean;
/** True when a prior in-flight migration state matches this target. */
resuming: boolean;
/** Set when the brain's reranker is also on the outgoing provider. */
reranker_warning: string | null;
}
export type MigrationApplyResult =
| { status: 'applied'; invalidated: number; cache_cleared: number; schema_transitioned: boolean }
| { status: 'refused'; reason: 'env_override'; warning: EnvOverrideWarning }
| { status: 'failed'; reason: string };
/** `<provider:model>:<dims>` — must match currentEmbeddingSignature()'s shape. */
export function migrationSignature(toModel: string, toDims: number): string {
return `${toModel}:${toDims}`;
}
/**
* Resolve + validate the target `provider:model` and dimensions.
* Throws with a paste-ready message on an unknown provider or when the
* recipe declares no default dims and the caller passed none.
*/
export function resolveMigrationTarget(to: string, dimFlag?: number): { toModel: string; toDims: number } {
if (!to.includes(':')) {
throw new Error(
`--to must be provider:model (e.g. openai:text-embedding-3-small). Got: ${to}`,
);
}
// Throws AIConfigError with provider list on an unknown provider.
const { recipe } = resolveRecipe(to);
if (!recipe.touchpoints.embedding) {
throw new Error(`Provider ${recipe.id} has no embedding support. Pick an embedding-capable provider:model.`);
}
const toDims = dimFlag ?? embeddingDimsForModel(recipe, to);
if (!toDims || toDims <= 0) {
throw new Error(
`No default dimension known for ${to}. Pass --dim <N> explicitly (see the provider's docs for valid values).`,
);
}
return { toModel: to, toDims };
}
/**
* Pure read: compute the migration workload. Uses the stale-chunk predicates
* with the TARGET signature + includeNullSignature so the count is
* resume-aware — a re-plan mid-migration counts only what remains.
*/
export async function planEmbeddingMigration(
engine: BrainEngine,
opts: { to: string; dim?: number; fromModel?: string; fromDims?: number },
): Promise<EmbeddingMigrationPlan> {
const { toModel, toDims } = resolveMigrationTarget(opts.to, opts.dim);
// From-state: caller (CLI) passes the gateway-resolved values; fall back
// to the shipped defaults for gateway-less contexts (unit tests, op probe).
const fromModel = opts.fromModel ?? DEFAULT_EMBEDDING_MODEL;
const fromDims = opts.fromDims ?? DEFAULT_EMBEDDING_DIMENSIONS;
const col = await readContentChunksEmbeddingDim(engine);
const sig = migrationSignature(toModel, toDims);
const wide = await engine.countStaleChunks({ signature: sig, includeNullSignature: true });
const narrow = await engine.countStaleChunks({ signature: sig });
const totalChars = await engine.sumStaleChunkChars({ signature: sig, includeNullSignature: true });
const price = lookupEmbeddingPrice(toModel);
const estCostUsd = price.kind === 'known'
? estimateCostFromChars(totalChars, price.pricePerMTok)
: 0;
let resuming = false;
try {
const stateStr = await engine.getConfig(MIGRATION_STATE_KEY);
if (stateStr) {
const state = JSON.parse(stateStr) as MigrationState;
resuming = state.to_model === toModel && state.to_dims === toDims;
}
} catch {
// Corrupt state marker — treat as fresh.
}
// Sunset companion warning: migrating embeddings off a provider whose
// reranker is still configured leaves rerank on the outgoing provider.
let rerankerWarning: string | null = null;
try {
const rr = await engine.getConfig('search.reranker.model');
const outgoingProvider = fromModel.split(':')[0];
const targetProvider = toModel.split(':')[0];
if (rr && outgoingProvider !== targetProvider && rr.startsWith(`${outgoingProvider}:`)) {
rerankerWarning =
`search.reranker.model is still ${rr} (the outgoing provider). ` +
`If that provider is sunsetting, also update or disable the reranker: ` +
`gbrain config set search.reranker.enabled false`;
}
} catch {
// Reranker warning is cosmetic.
}
return {
from_model: fromModel,
from_dims: fromDims,
column_dims: col.dims,
to_model: toModel,
to_dims: toDims,
dim_change: col.dims !== null && col.dims !== toDims,
chunks_to_embed: wide,
total_chars: totalChars,
null_signature_chunks: wide - narrow,
est_cost_usd: estCostUsd,
price_known: price.kind === 'known',
resuming,
reranker_warning: rerankerWarning,
};
}
/**
* Apply the non-embed half of the migration: env gate, state marker, schema
* transition (dim changes only), DB-plane config, file-plane persistence
* (via callback — the core module never touches ~/.gbrain), stale-signature
* invalidation (#3391: includeNullSignature), and query-cache purge.
*
* Ordering makes every step idempotent under a crash + re-run:
* state marker → schema → config → invalidate → cache purge.
* A crash anywhere leaves the state marker set; the re-run re-executes the
* remaining steps (schema transition no-ops when the column is already at
* the target width via the actual-width probe; invalidation matches nothing
* the second time).
*/
export async function applyEmbeddingMigration(
engine: BrainEngine,
plan: EmbeddingMigrationPlan,
opts: {
ignoreEnvOverride?: boolean;
/** Persist target model+dims to the file plane + reconfigure the gateway. */
persistConfig?: (toModel: string, toDims: number) => void | Promise<void>;
} = {},
): Promise<MigrationApplyResult> {
const envWarning = detectEnvOverride(plan.to_model, plan.to_dims);
if (envWarning.triggered && !opts.ignoreEnvOverride) {
return { status: 'refused', reason: 'env_override', warning: envWarning };
}
try {
// 1. State marker FIRST — a crash after any later step is resumable.
const state: MigrationState = {
to_model: plan.to_model,
to_dims: plan.to_dims,
from_model: plan.from_model,
from_dims: plan.from_dims,
started_at: new Date().toISOString(),
};
await engine.setConfig(MIGRATION_STATE_KEY, JSON.stringify(state));
// 2. Schema transition when the ACTUAL column width differs from the
// target (probe again — the plan may be stale after a resume).
let schemaTransitioned = false;
const col = await readContentChunksEmbeddingDim(engine);
if (col.dims !== plan.to_dims) {
await runSchemaTransition(engine, plan.to_dims);
schemaTransitioned = true;
}
// 3. #3391: mark EVERYTHING not in the target space as stale, including
// NULL-signature (pre-v108) pages. After a schema transition this is
// a cheap no-op (the column rebuild already nulled every embedding).
//
// ORDERING (adversarial review): invalidation MUST precede the config
// writes below. On a SAME-dim provider swap there is no schema
// transition to null the vectors, so a crash between "config says new
// provider" and "old vectors invalidated" would leave NEW-space query
// embeddings scored against OLD-space document vectors — silently
// WRONG results. Invalidating first makes the crash window safe:
// config still says the old provider, and the rows are merely stale
// (empty/degraded results, never wrong ones).
const invalidated = await engine.invalidateStaleSignatureEmbeddings({
signature: migrationSignature(plan.to_model, plan.to_dims),
includeNullSignature: true,
});
// 4. DB-plane config (doctor's embedding_width_consistency reads these).
await engine.setConfig('embedding_model', plan.to_model);
await engine.setConfig('embedding_dimensions', String(plan.to_dims));
// 5. File plane + gateway (the embed pipeline reads file/env, not DB).
await opts.persistConfig?.(plan.to_model, plan.to_dims);
// 6. Purge the semantic query cache. The knobs hash folds provider:model
// for callers that thread KnobsHashContext, but legacy callers fall
// back to 'default' — a row they wrote pre-migration must not be
// served post-migration. Best-effort (cache must never block).
let cacheCleared = 0;
try {
const { SemanticQueryCache } = await import('./search/query-cache.ts');
cacheCleared = await new SemanticQueryCache(engine).clear({});
} catch {
// Table may not exist on old brains; a miss here is harmless.
}
return { status: 'applied', invalidated, cache_cleared: cacheCleared, schema_transitioned: schemaTransitioned };
} catch (err) {
return { status: 'failed', reason: err instanceof Error ? err.message : String(err) };
}
}
/**
* Stamp the target signature on every page that is fully embedded but not yet
* stamped. Call after the re-embed drain, BEFORE the completion probe.
*
* Why this exists (adversarial review): the embed loop only stamps a page when
* `stale.length === existing.length` — i.e. when every one of the page's
* chunks was in the SAME batch. `listStaleChunks` is a plain keyset LIMIT with
* no page alignment, so on any corpus larger than one batch (default 2000
* chunks) the page straddling each boundary is embedded correctly but never
* stamped. Without this reconcile the command reports "incomplete" + exit 1 on
* a perfectly-migrated brain, and the re-run re-invalidates and PAYS AGAIN for
* those pages — breaking the "already-migrated chunks are never re-embedded"
* contract.
*
* Safety: this is only sound because `applyEmbeddingMigration` invalidated
* (NULLed) every chunk that was NOT already in the target space. So "page has
* zero NULL-embedding chunks" ⇒ "every chunk on this page was embedded in the
* target space during this run". Pages with any remaining NULL chunk (a real
* embed failure) are deliberately left unstamped so the completion probe still
* reports them.
*
* Returns the number of pages stamped.
*/
export async function reconcilePageSignatures(
engine: BrainEngine,
plan: EmbeddingMigrationPlan,
): Promise<number> {
const sig = migrationSignature(plan.to_model, plan.to_dims);
const rows = await engine.executeRaw<{ slug: string }>(
`UPDATE pages p
SET embedding_signature = $1
WHERE p.deleted_at IS NULL
AND (p.embedding_signature IS DISTINCT FROM $1)
AND EXISTS (SELECT 1 FROM content_chunks c WHERE c.page_id = p.id)
AND NOT EXISTS (
SELECT 1 FROM content_chunks c
WHERE c.page_id = p.id AND c.embedding IS NULL
)
RETURNING p.slug`,
[sig],
);
return (rows as unknown[]).length;
}
/**
* Finish bookkeeping after the re-embed drains: clear the in-flight marker,
* stamp the completion record. Call ONLY when countStaleChunks() === 0.
*/
export async function completeEmbeddingMigration(
engine: BrainEngine,
plan: EmbeddingMigrationPlan,
): Promise<void> {
await engine.unsetConfig(MIGRATION_STATE_KEY);
await engine.setConfig(
MIGRATION_COMPLETED_KEY,
JSON.stringify({
to_model: plan.to_model,
to_dims: plan.to_dims,
from_model: plan.from_model,
completed_at: new Date().toISOString(),
}),
);
}