Compare commits

...
Author SHA1 Message Date
Garry TanandClaude Fable 5 e9a4b4cd31 test(takes): move fresh PGLiteEngine into nested beforeAll (test-isolation R3)
check:test-isolation flagged new PGLiteEngine() inside a test body in
test/embed-stale-takes.test.ts. The v125 migration describe now creates
its fresh engine in its own beforeAll/afterAll, per the canonical pattern
in test/helpers/reset-pglite.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 10:47:25 -07:00
Garry TanandClaude Fable 5 567813028a fix(takes): wire the takes.embedding writer so vector takes search works (#2089)
takes.embedding had readers (searchTakesVector, think's takes_vec arm,
getTakeEmbeddings) and stale-tracking (countStaleTakes/listStaleTakes)
but no writer anywhere in src/ — the vector takes arm was structurally
dead on every install.

- BrainEngine.updateTakeEmbeddings (postgres + pglite, lockstep): writes
  embedding + embedded_at for active takes; updated_at untouched
  (embeddings are derived data, not a content change).
- embedStaleTakes in src/commands/embed.ts: claim-text backfill wired
  into the --stale path, so CLI `embed --stale`, the cycle/dream embed
  phase, and sync auto-embed all populate takes.embedding. Log-and-skip
  semantics; dry-run counts into would_embed; EmbedResult.takes_embedded
  reports the count.
- Migration v125 (takes_embedding_dim_align): the takes DDL hardcoded
  VECTOR(1536); brains configured with a different-dim embedder could
  never take writes. Since the column was writer-less it is all-NULL
  everywhere, so a guarded DROP/ADD retype to the configured dims (same
  config source as the facts migration) is lossless and works on PGLite
  too (which can't ALTER COLUMN TYPE vector(N)). No-op when dims match
  or data exists.
- test/embed-stale-takes.test.ts: fails without the writer (backfill,
  dry-run, idempotence, searchTakesVector goes live, v125 retype).

Fixes #2089

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 15:05:28 -07:00
7 changed files with 324 additions and 1 deletions
+79 -1
View File
@@ -130,6 +130,13 @@ export interface EmbedResult {
pages_processed: number;
/** True if this run was a dry-run. */
dryRun: boolean;
/**
* #2089: take claims newly embedded in this run (the `--stale` path also
* backfills `takes.embedding` so the vector takes arm — think takes_vec —
* has data to search). Present only when > 0. In dryRun mode stale takes
* are counted into `would_embed` instead.
*/
takes_embedded?: number;
/**
* E1 (paced-backfill): end-of-run pacing telemetry. Present ONLY when pacing
* was active (enabled bundle). The number the operator could not get from an
@@ -644,7 +651,17 @@ async function embedAll(
// D7: thread sourceId so `gbrain embed --stale --source X` actually scopes.
// v0.41.18.0 (A13): thread batchSize/priority/catchUp into the stale path.
// #1737: thread the external abort signal so the cycle embed phase bails.
return await embedAllStale(engine, sourceId, dryRun, result, onProgress, staleOpts, signature, signal);
await embedAllStale(engine, sourceId, dryRun, result, onProgress, staleOpts, signature, signal);
// #2089: backfill takes.embedding — before this pass nothing ever wrote
// it, so the vector takes arm (searchTakesVector / think takes_vec) was
// structurally dead. Runs on every --stale caller (CLI, cycle embed
// phase, sync auto-embed). listStaleTakes is brain-wide (not
// source-scoped) — embedding another source's takes is a harmless
// idempotent write, not a read-side leak.
if (!isAborted(signal)) {
await embedStaleTakes(engine, dryRun, result, signal);
}
return;
}
// --all path: pacer (no-op when off). E-1: lower the worker count to the
@@ -770,6 +787,67 @@ async function embedAll(
}
}
/**
* #2089: embed stale take claims (`active AND embedding IS NULL`) so the
* vector takes search arm has data. Mirrors embedAllStale's log-and-skip
* semantics: a failure leaves the remaining takes stale (retried next run)
* and never throws. Exported with an `embedFn` seam for tests (same pattern
* as embedStaleForSource).
*/
export async function embedStaleTakes(
engine: BrainEngine,
dryRun: boolean,
result: EmbedResult,
signal?: AbortSignal,
embedFn: (texts: string[], o: { abortSignal?: AbortSignal }) => Promise<Float32Array[]> =
(texts, o) => embedBatchWithBackoff(texts, { abortSignal: o.abortSignal }),
): Promise<void> {
let staleCount: number;
try {
// ?? 0: tolerate partial engines (test mocks) that don't implement takes.
staleCount = Number(await engine.countStaleTakes() ?? 0);
} catch (e: unknown) {
// Pre-takes-table brain (partial upgrade) — nothing to do.
serr(` [embed] takes: stale count failed: ${e instanceof Error ? e.message : e}`);
return;
}
if (!staleCount) return;
if (dryRun) {
result.would_embed += staleCount;
slog(`[dry-run] Would embed ${staleCount} stale take claim(s)`);
return;
}
const rows = (await engine.listStaleTakes()) ?? [];
const BATCH = 100;
let embedded = 0;
for (let i = 0; i < rows.length; i += BATCH) {
if (isAborted(signal)) break;
const batch = rows.slice(i, i + BATCH);
try {
const embeddings = await embedFn(batch.map((r) => r.claim), { abortSignal: signal });
embedded += await engine.updateTakeEmbeddings(
batch.map((r, j) => ({ take_id: r.take_id, embedding: embeddings[j] })),
);
} catch (e: unknown) {
if (isAborted(signal)) break;
// ponytail: stop on first failure (dim mismatch against the takes
// vector column, provider outage) instead of grinding every batch into
// the same error; the remaining takes stay NULL and retry next --stale.
serr(
` [embed] takes: failed at ${batch[0].page_slug}#${batch[0].row_num}: ` +
`${e instanceof Error ? e.message : e}; remaining stale takes will retry next run`,
);
break;
}
}
if (embedded > 0) {
result.takes_embedded = (result.takes_embedded ?? 0) + embedded;
slog(`Embedded ${embedded} take claim(s)`);
}
}
/**
* SQL-side stale path: replaces the listPages + per-page getChunks
* walk with a count + slug-grouped SELECT. Preserves the existing
+8
View File
@@ -1524,6 +1524,14 @@ export interface BrainEngine {
/** List stale takes (no embedding column in payload — same pattern as listStaleChunks). */
listStaleTakes(): Promise<StaleTakeRow[]>;
/**
* #2089: write claim embeddings for takes (the write side of
* searchTakesVector). Sets `embedding` + `embedded_at`; does NOT bump
* `updated_at` (embeddings are derived data, not a content change).
* Returns the number of rows updated (inactive/deleted rows are skipped).
*/
updateTakeEmbeddings(rows: Array<{ take_id: number; embedding: Float32Array }>): Promise<number>;
/**
* Update a take's mutable fields. May NOT change claim/kind/holder per the
* supersession invariants — those route through supersedeTake. Throws
+56
View File
@@ -5671,6 +5671,62 @@ export const MIGRATIONS: Migration[] = [
`);
},
},
{
version: 125,
name: 'takes_embedding_dim_align',
// #2089: the takes migration hardcoded `embedding VECTOR(1536)` while
// content_chunks/facts resolve dims from config, so brains with a
// non-1536 embedder (e.g. Voyage 1024) could never write a take
// embedding. Before v0.42.x there was ALSO no writer at all, so the
// column is all-NULL on every install — retyping it to the configured
// dims loses nothing. DROP/ADD (not ALTER TYPE) so the same path works
// on PGLite, which can't ALTER COLUMN TYPE vector(N). Guarded: any
// non-NULL embedding present → no-op (the current type demonstrably
// works for that brain). All takes access uses explicit column lists,
// so column-order change from DROP/ADD is safe.
idempotent: true,
sql: '',
handler: async (engine: BrainEngine) => {
let dims = 1536;
try {
const dimRows = await engine.executeRaw<{ value: string }>(
`SELECT value FROM config WHERE key = 'embedding_dimensions'`,
);
const parsed = parseInt(dimRows[0]?.value ?? '', 10);
if (Number.isFinite(parsed) && parsed > 0 && parsed <= 16000) dims = parsed;
} catch { /* no config row — keep default, which matches the DDL */ }
const colRows = await engine.executeRaw<{ formatted: string | null }>(
`SELECT format_type(a.atttypid, a.atttypmod) AS formatted
FROM pg_attribute a
JOIN pg_class c ON c.oid = a.attrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relname = 'takes'
AND a.attname = 'embedding' AND NOT a.attisdropped`,
);
const m = (colRows[0]?.formatted ?? '').match(/vector\((\d+)\)/i);
const colDims = m ? parseInt(m[1], 10) : null;
if (colDims === null || colDims === dims) return;
const dataRows = await engine.executeRaw<{ n: number }>(
`SELECT count(*)::int AS n FROM takes WHERE embedding IS NOT NULL`,
);
if (Number(dataRows[0]?.n ?? 0) > 0) {
process.stderr.write(` v125: takes.embedding is vector(${colDims}) (config says ${dims}) but already holds data; leaving it alone\n`);
return;
}
await engine.executeRaw(`DROP INDEX IF EXISTS idx_takes_embedding_hnsw`);
await engine.executeRaw(`ALTER TABLE takes DROP COLUMN embedding`);
await engine.executeRaw(`ALTER TABLE takes ADD COLUMN embedding VECTOR(${dims})`);
await engine.executeRaw(
`CREATE INDEX IF NOT EXISTS idx_takes_embedding_hnsw ON takes
USING hnsw (embedding vector_cosine_ops)
WHERE active AND embedding IS NOT NULL`,
);
process.stderr.write(` v125: takes.embedding retyped vector(${colDims}) → vector(${dims}) to match the configured embedder (#2089)\n`);
},
},
];
export const LATEST_VERSION = MIGRATIONS.length > 0
+18
View File
@@ -4869,6 +4869,24 @@ export class PGLiteEngine implements BrainEngine {
return rows as unknown as StaleTakeRow[];
}
async updateTakeEmbeddings(rows: Array<{ take_id: number; embedding: Float32Array }>): Promise<number> {
if (rows.length === 0) return 0;
let updated = 0;
// ponytail: per-row UPDATE loop — takes volume is small (hundreds, not
// millions); switch to an unnest batch if listStaleTakes ever pages.
for (const r of rows) {
const vec = `[${Array.from(r.embedding).join(',')}]`;
const res = await this.db.query(
`UPDATE takes SET embedding = $2::vector, embedded_at = now()
WHERE id = $1 AND active
RETURNING 1`,
[r.take_id, vec]
);
updated += res.rows.length;
}
return updated;
}
async updateTake(
pageId: number,
rowNum: number,
+18
View File
@@ -5004,6 +5004,24 @@ export class PostgresEngine implements BrainEngine {
return rows as unknown as StaleTakeRow[];
}
async updateTakeEmbeddings(rows: Array<{ take_id: number; embedding: Float32Array }>): Promise<number> {
if (rows.length === 0) return 0;
const sql = this.sql;
let updated = 0;
// ponytail: per-row UPDATE loop — takes volume is small (hundreds, not
// millions); switch to an unnest batch if listStaleTakes ever pages.
for (const r of rows) {
const vec = `[${Array.from(r.embedding).join(',')}]`;
const res = await sql`
UPDATE takes SET embedding = ${vec}::vector, embedded_at = now()
WHERE id = ${r.take_id} AND active
RETURNING 1
`;
updated += res.length;
}
return updated;
}
async updateTake(
pageId: number,
rowNum: number,
+138
View File
@@ -0,0 +1,138 @@
/**
* #2089: takes.embedding write path. Before this fix nothing ever wrote
* takes.embedding, so the vector takes arm (searchTakesVector / think
* takes_vec) was structurally dead on every install. Covers:
* - embedStaleTakes backfills stale claims (injected embedFn, no gateway)
* - dry-run counts without writing
* - searchTakesVector actually returns hits once embeddings exist
* - migration v125 retypes the hardcoded vector(1536) column to the
* configured embedder dims when the column is empty
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { embedStaleTakes, type EmbedResult } from '../src/commands/embed.ts';
import { MIGRATIONS } from '../src/core/migrate.ts';
let engine: PGLiteEngine;
let pageId: number;
const DIMS = 1536;
/** Deterministic fake embedder: unit vector rotated by first char code. */
function fakeEmbed(text: string): Float32Array {
const v = new Float32Array(DIMS);
v[text.charCodeAt(0) % DIMS] = 1;
return v;
}
const embedFn = async (texts: string[]) => texts.map(fakeEmbed);
function freshResult(dryRun = false): EmbedResult {
return { embedded: 0, skipped: 0, would_embed: 0, total_chunks: 0, pages_processed: 0, dryRun };
}
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
const page = await engine.putPage('people/alice-example', {
title: 'Alice Example',
type: 'person' as const,
compiled_truth: '## Takes\n\nAlice is a strong founder.\n',
});
pageId = page.id;
await engine.addTakesBatch([
{ page_id: pageId, row_num: 1, claim: 'Alice is a strong technical founder', kind: 'take', holder: 'garry', weight: 0.9 },
{ page_id: pageId, row_num: 2, claim: 'Zebra stripes are cosmetic', kind: 'hunch', holder: 'garry', weight: 0.4 },
]);
});
afterAll(async () => {
await engine.disconnect();
});
describe('embedStaleTakes (#2089)', () => {
test('dry-run counts stale takes into would_embed without writing', async () => {
const result = freshResult(true);
await embedStaleTakes(engine, true, result, undefined, embedFn);
expect(result.would_embed).toBe(2);
expect(await engine.countStaleTakes()).toBe(2);
});
test('backfills stale take embeddings and makes searchTakesVector live', async () => {
expect(await engine.countStaleTakes()).toBe(2);
const result = freshResult();
await embedStaleTakes(engine, false, result, undefined, embedFn);
expect(result.takes_embedded).toBe(2);
expect(await engine.countStaleTakes()).toBe(0);
// The read side that was structurally dead: query with the same fake
// embedding as the 'Alice...' claim → that take ranks first.
const hits = await engine.searchTakesVector(fakeEmbed('Alice is a strong technical founder'), { limit: 5 });
expect(hits.length).toBeGreaterThan(0);
expect(hits[0].claim).toBe('Alice is a strong technical founder');
expect(hits[0].score).toBeCloseTo(1, 5);
});
test('second run is a no-op (embedding IS NULL predicate)', async () => {
const result = freshResult();
await embedStaleTakes(engine, false, result, undefined, async (texts) => {
throw new Error(`should not be called, got ${texts.length} texts`);
});
expect(result.takes_embedded).toBeUndefined();
});
});
describe('migration v125: takes.embedding dim align (#2089)', () => {
// Fresh engine so the column is untouched (all NULL).
let e2: PGLiteEngine;
beforeAll(async () => {
e2 = new PGLiteEngine();
await e2.connect({});
await e2.initSchema();
});
afterAll(async () => {
await e2.disconnect();
});
test('retypes an all-NULL 1536 column to the configured dims', async () => {
await e2.executeRaw(`UPDATE config SET value = '8' WHERE key = 'embedding_dimensions'`);
const v125 = MIGRATIONS.find((m) => m.version === 125);
expect(v125?.handler).toBeDefined();
await v125!.handler!(e2);
const rows = await e2.executeRaw<{ formatted: string }>(
`SELECT format_type(a.atttypid, a.atttypmod) AS formatted
FROM pg_attribute a
JOIN pg_class c ON c.oid = a.attrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relname = 'takes'
AND a.attname = 'embedding' AND NOT a.attisdropped`,
);
expect(rows[0].formatted).toBe('vector(8)');
// And the write path works at the new dims.
const page = await e2.putPage('companies/acme-example', {
title: 'Acme', type: 'company' as const, compiled_truth: 'Acme.\n',
});
await e2.addTakesBatch([
{ page_id: page.id, row_num: 1, claim: 'B2B SaaS', kind: 'fact', holder: 'world', weight: 1 },
]);
const updated = await e2.updateTakeEmbeddings([
{ take_id: (await e2.listStaleTakes())[0].take_id, embedding: new Float32Array([1, 0, 0, 0, 0, 0, 0, 0]) },
]);
expect(updated).toBe(1);
expect(await e2.countStaleTakes()).toBe(0);
});
test('no-op when column dims already match config', async () => {
// The main engine is vector(1536) with config 1536: handler returns
// without touching the (now populated) column.
const v125 = MIGRATIONS.find((m) => m.version === 125);
await v125!.handler!(engine);
expect(await engine.countStaleTakes()).toBe(0); // embeddings survived
});
});
+7
View File
@@ -691,6 +691,13 @@ const COLUMN_EXEMPTIONS = new Set<string>([
//
// Refreshing PGLITE_SCHEMA_SQL is a separate concern handled by
// `bun run build:schema` from src/schema.sql; not gated by this test.
// v125 (#2089): takes.embedding is not NEW — the takes table (migration
// v37) already carries it. v125's ADD COLUMN is the second half of a
// DROP/ADD retype to the configured embedder dims (all-NULL column, so
// nothing to convert), guarded to run only on dim mismatch. The takes
// table is migration-created (absent from the schema blob), so there is
// no forward reference for the bootstrap to cover.
'takes.embedding',
'minion_jobs.quiet_hours',
'minion_jobs.stagger_key',
'sources.chunker_version',