* feat(worker-pool): shared sliding pool + bounded semaphore + PGLite-clamp wrapper T1 + T2 of the v0.41.16.0 workers cathedral. New src/core/worker-pool.ts is the canonical primitive every --workers N bulk command in this wave (and future bulk commands) builds on. Atomic-claim invariant enforced by scripts/check-worker-pool-atomicity.sh (wired into bun run verify). BudgetExhausted bypass + AbortSignal composition baked into the helper so budget caps are a structural ceiling under concurrency, not a per-caller convention. The new resolveWorkersWithClamp wrapper composes existing autoConcurrency with PGLite-clamp + per-(command, requested) stderr dedup. Deliberately NOT a modification to shared autoConcurrency (silent today, used by sync + import); embed.ts keeps GBRAIN_EMBED_CONCURRENCY || 20 default per codex #13. 23 + 12 + 9 = 44 hermetic tests pin every contract. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: structural + dim-check regression suites for v0.41.16.0 wave - test/embed-helper-migration.test.ts (T3): asserts embed.ts's two sliding-pool sites are migrated to runSlidingPool, pre-migration shapes (let nextIdx = 0, Promise.all(Array.from(...))) are gone, GBRAIN_EMBED_CONCURRENCY || 20 default preserved, failureLabel threads page.slug. Per codex #16/#17 these are invariant assertions, not byte-equality on progress event ORDERING. - test/embedding-dim-check-facts.test.ts (T6): readFactsEmbeddingDim covers vector(N) + halfvec(N), halfvec-before-vector regex ordering pinned (codex #19), buildFactsAlterRecipe emits DROP INDEX + ALTER USING + CREATE INDEX (codex #18, not bare REINDEX), FactsEmbeddingDimMismatchError tagged class shape, assertFactsEmbeddingDimMatchesConfig PGLite skip + Postgres absent- column skip, doctor check + insert-cast wiring assertions. - test/extract-conversation-facts-workers.test.ts (T5): helper exports (extractConversationFactsLockId, PER_PAGE_LOCK_TTL_MINUTES), structural wiring (runSlidingPool, resolveWorkersWithClamp, withRefreshingLock, LockUnavailableError, delete-orphans-first before segment loop, preflight before pool, exit 3 when lock_skipped > 0), Minion handler round-trip. - test/extract-workers.test.ts (T7): --workers wiring on all 3 inner fs-walk loops (extractForSlugs, extractLinksFromDir, extractTimelineFromDir) + CLI parse + opts threading through runExtractCore. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: rebump v0.41.16.0 → v0.41.17.0 (queue collision with PR #1510) PR #1510 (garrytan/dynamic-regex-conversation-formats) claimed v0.41.16.0 on master in parallel. Advancing this wave to v0.41.17.0 so both can land cleanly. Pure mechanical version bump: - VERSION + package.json → 0.41.17.0 - CHANGELOG.md header + "To take advantage of v0.41.17.0" block - TODOS.md section header + v0.41.18+ forward references - CLAUDE.md inline version tags - Regenerated llms-full.txt / llms.txt No code changes. The actual workers cathedral feature set is unchanged from the two prior commits in this branch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(test): search-image-column probes column dim at runtime CI shard 5 failed on `searchVector column routing (v0.27.1)` with: error: expected 1280 dimensions, not 1536 The test had a hardcoded `fakeText1536` helper that seeded chunks at 1536-d vectors. Master's default embedding model switched from OpenAI text-embedding-3-large (1536) to ZeroEntropy zembed-1 (1280) so a fresh PGLite brain on CI now sizes content_chunks.embedding at 1280; the test's 1536-d INSERT trips pgvector's CheckExpectedDim. Fix: probe `content_chunks.embedding` width via `readContentChunksEmbeddingDim(engine)` in `beforeAll`, store in `TEXT_DIM`, and build `fakeTextDefault(seed)` at that width. The test now passes regardless of which default ships (the model has flipped twice and may flip again). Local dev (1536 from older config) and CI fresh-install (1280 from new default) both pass. Image-side vectors stay at 1024 (matches Voyage multimodal-3 + the column's fixed width on the image side). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(test): bump PGLite hook timeout for shard-4 deep-process files facts-anti-loop.test.ts and ingest-capture.test.ts were timing out in CI shard 4 with "beforeEach/afterEach hook timed out" after the v0.41.16.0 master merge brought migration count to 99. When these files run deep in a shard process that has already created ~20 PGLite engines, the WASM cold-start + 95-migration replay legitimately exceeds bun's 5s default hook timeout (observed 5.6s and 7.3s locally when reproducing). Bun's --timeout=60000 from scripts/test-shard.sh covers TEST timeouts but NOT hook timeouts; those default to 5s and must be set per-hook via the optional 2nd arg to beforeAll/afterAll. Reproduced locally by running the first 21 shard-4 files via head -21 /tmp/shard4-list.txt | xargs bun test → 179 pass, 2 fail (both with hook-timeout error) After fix: → 198 pass, 0 fail (the 4 anti-loop + 15 ingest-capture tests recover) Full shard 4 with fix: 955 pass, 0 fail. Full shard 5 with fix: 1261 pass, 0 fail. Also added a defensive diagnostic to the two put_page tests: if facts_backstop is missing in the response payload, throw with the full payload + isError so future failures surface the actual handler error instead of a bare "expected {...} got undefined" assertion. No-op when the test passes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
226 lines
8.5 KiB
TypeScript
226 lines
8.5 KiB
TypeScript
/**
|
|
* Hermetic tests for the facts.embedding dim-check + preflight surface
|
|
* added in v0.41.15.0 T6.
|
|
*
|
|
* Covers:
|
|
* - readFactsEmbeddingDim: column-absent, vector(N), halfvec(N), unrecognized
|
|
* - buildFactsAlterRecipe: vector vs halfvec opclass + targetType
|
|
* - FactsEmbeddingDimMismatchError shape + tag
|
|
* - assertFactsEmbeddingDimMatchesConfig: PGLite skip, match, drift throws
|
|
* - doctor's checkFactsEmbeddingWidthConsistency: wired into the suite
|
|
*
|
|
* No DB needed for most cases — readFactsEmbeddingDim goes through
|
|
* `engine.executeRaw`, which we stub with a tiny in-test fake engine.
|
|
* The PGLite-engine skip case uses `{kind: 'pglite'}`. R1+R2 compliant.
|
|
*/
|
|
|
|
import { describe, test, expect } from 'bun:test';
|
|
import {
|
|
readFactsEmbeddingDim,
|
|
buildFactsAlterRecipe,
|
|
FactsEmbeddingDimMismatchError,
|
|
assertFactsEmbeddingDimMatchesConfig,
|
|
} from '../src/core/embedding-dim-check.ts';
|
|
import type { BrainEngine } from '../src/core/engine.ts';
|
|
import { readFileSync } from 'fs';
|
|
import { resolve } from 'path';
|
|
|
|
/**
|
|
* Synthetic engine satisfying the slice of BrainEngine these helpers
|
|
* touch: `kind` discriminator + `executeRaw<T>(sql, params?)`. Tests
|
|
* pre-program responses keyed on substring matches in the SQL.
|
|
*/
|
|
function makeStubEngine(opts: {
|
|
kind: 'postgres' | 'pglite';
|
|
factsExists?: boolean;
|
|
factsFormatted?: string | null;
|
|
}): BrainEngine {
|
|
const exists = opts.factsExists ?? false;
|
|
const formatted = opts.factsFormatted ?? null;
|
|
const eng = {
|
|
kind: opts.kind,
|
|
async executeRaw<T>(sql: string): Promise<T[]> {
|
|
if (sql.includes('information_schema.columns') && sql.includes("'facts'")) {
|
|
return [{ exists }] as unknown as T[];
|
|
}
|
|
if (sql.includes('format_type') && sql.includes("'facts'")) {
|
|
return [{ formatted }] as unknown as T[];
|
|
}
|
|
return [] as T[];
|
|
},
|
|
};
|
|
return eng as unknown as BrainEngine;
|
|
}
|
|
|
|
describe('readFactsEmbeddingDim', () => {
|
|
test('returns exists=false when facts.embedding column is absent', async () => {
|
|
const eng = makeStubEngine({ kind: 'postgres', factsExists: false });
|
|
const r = await readFactsEmbeddingDim(eng);
|
|
expect(r.exists).toBe(false);
|
|
expect(r.dims).toBeNull();
|
|
expect(r.columnType).toBeNull();
|
|
});
|
|
|
|
test('parses halfvec(N) shape', async () => {
|
|
const eng = makeStubEngine({
|
|
kind: 'postgres',
|
|
factsExists: true,
|
|
factsFormatted: 'halfvec(1536)',
|
|
});
|
|
const r = await readFactsEmbeddingDim(eng);
|
|
expect(r.exists).toBe(true);
|
|
expect(r.dims).toBe(1536);
|
|
expect(r.columnType).toBe('halfvec');
|
|
});
|
|
|
|
test('parses vector(N) shape', async () => {
|
|
const eng = makeStubEngine({
|
|
kind: 'postgres',
|
|
factsExists: true,
|
|
factsFormatted: 'vector(1024)',
|
|
});
|
|
const r = await readFactsEmbeddingDim(eng);
|
|
expect(r.exists).toBe(true);
|
|
expect(r.dims).toBe(1024);
|
|
expect(r.columnType).toBe('vector');
|
|
});
|
|
|
|
test('returns null columnType when format_type returns null', async () => {
|
|
const eng = makeStubEngine({
|
|
kind: 'postgres',
|
|
factsExists: true,
|
|
factsFormatted: null,
|
|
});
|
|
const r = await readFactsEmbeddingDim(eng);
|
|
expect(r.exists).toBe(true);
|
|
expect(r.dims).toBeNull();
|
|
expect(r.columnType).toBeNull();
|
|
});
|
|
|
|
test('halfvec match preferred over vector match (codex #19 regex shadowing)', async () => {
|
|
// The substring "vec" appears in "halfvec"; a naive /vector/i regex
|
|
// would shadow the halfvec branch. Pin the ordering invariant.
|
|
const eng = makeStubEngine({
|
|
kind: 'postgres',
|
|
factsExists: true,
|
|
factsFormatted: 'halfvec(1280)',
|
|
});
|
|
const r = await readFactsEmbeddingDim(eng);
|
|
expect(r.columnType).toBe('halfvec');
|
|
expect(r.dims).toBe(1280);
|
|
});
|
|
});
|
|
|
|
describe('buildFactsAlterRecipe', () => {
|
|
test('halfvec recipe uses halfvec_cosine_ops + halfvec(N) USING cast', () => {
|
|
const recipe = buildFactsAlterRecipe(1536, 1280, 'halfvec');
|
|
expect(recipe).toContain('DROP INDEX IF EXISTS idx_facts_embedding_hnsw');
|
|
expect(recipe).toContain('halfvec(1280)');
|
|
expect(recipe).toContain('USING embedding::halfvec(1280)');
|
|
expect(recipe).toContain('halfvec_cosine_ops');
|
|
expect(recipe).not.toContain('vector_cosine_ops');
|
|
});
|
|
|
|
test('vector recipe uses vector_cosine_ops + vector(N) USING cast', () => {
|
|
const recipe = buildFactsAlterRecipe(1024, 2048, 'vector');
|
|
expect(recipe).toContain('vector(2048)');
|
|
expect(recipe).toContain('USING embedding::vector(2048)');
|
|
expect(recipe).toContain('vector_cosine_ops');
|
|
expect(recipe).not.toContain('halfvec_cosine_ops');
|
|
});
|
|
|
|
test('recipe carries the maintenance-window warning (codex #18)', () => {
|
|
const recipe = buildFactsAlterRecipe(1536, 1280, 'halfvec');
|
|
expect(recipe).toMatch(/maintenance window/i);
|
|
expect(recipe).toContain('rewrites every row');
|
|
});
|
|
|
|
test('recipe is the full DROP → ALTER → CREATE flow, not just REINDEX', () => {
|
|
// Codex #18 specifically called out that REINDEX alone after ALTER
|
|
// TYPE isn't sufficient — pgvector won't pick up the new column type
|
|
// on the partial HNSW index. The recipe must be DROP + ALTER + CREATE.
|
|
const recipe = buildFactsAlterRecipe(1536, 1280, 'halfvec');
|
|
expect(recipe).toMatch(/DROP INDEX[\s\S]*ALTER TABLE[\s\S]*CREATE INDEX/);
|
|
});
|
|
});
|
|
|
|
describe('FactsEmbeddingDimMismatchError', () => {
|
|
test('tag matches the worker-pool MUST_ABORT semantics for D13 parity', () => {
|
|
const err = new FactsEmbeddingDimMismatchError(
|
|
'test',
|
|
1536,
|
|
1280,
|
|
'halfvec',
|
|
);
|
|
// Tag-based dispatch (mirrors BudgetExhausted shape).
|
|
expect(err.tag).toBe('FACTS_EMBEDDING_DIM_MISMATCH');
|
|
expect(err.name).toBe('FactsEmbeddingDimMismatchError');
|
|
expect(err instanceof Error).toBe(true);
|
|
expect(err.columnDims).toBe(1536);
|
|
expect(err.configuredDims).toBe(1280);
|
|
expect(err.columnType).toBe('halfvec');
|
|
});
|
|
});
|
|
|
|
describe('assertFactsEmbeddingDimMatchesConfig', () => {
|
|
test('PGLite engines silently skip (no probe, no throw)', async () => {
|
|
const eng = makeStubEngine({ kind: 'pglite' });
|
|
// Should resolve without throwing — PGLite branch short-circuits.
|
|
await assertFactsEmbeddingDimMatchesConfig(eng);
|
|
});
|
|
|
|
test('Postgres without facts column resolves cleanly (pre-v40 path)', async () => {
|
|
const eng = makeStubEngine({ kind: 'postgres', factsExists: false });
|
|
await assertFactsEmbeddingDimMatchesConfig(eng);
|
|
});
|
|
});
|
|
|
|
describe('doctor checkFactsEmbeddingWidthConsistency wiring (T6)', () => {
|
|
const DOC_PATH = resolve(import.meta.dir, '..', 'src/commands/doctor.ts');
|
|
const DOC_SRC = readFileSync(DOC_PATH, 'utf-8');
|
|
|
|
test('doctor.ts exports the new check function', () => {
|
|
expect(DOC_SRC).toMatch(
|
|
/export\s+async\s+function\s+checkFactsEmbeddingWidthConsistency/,
|
|
);
|
|
});
|
|
|
|
test('check is registered in runDoctor alongside the content_chunks check', () => {
|
|
expect(DOC_SRC).toMatch(/checkFactsEmbeddingWidthConsistency\(engine\)/);
|
|
// Must appear AFTER the content_chunks check so a single
|
|
// mismatch surface ordering is stable in the JSON envelope.
|
|
const widthIdx = DOC_SRC.indexOf('checkEmbeddingWidthConsistency(engine)');
|
|
const factsIdx = DOC_SRC.indexOf('checkFactsEmbeddingWidthConsistency(engine)');
|
|
expect(widthIdx).toBeGreaterThan(0);
|
|
expect(factsIdx).toBeGreaterThan(0);
|
|
expect(widthIdx).toBeLessThan(factsIdx);
|
|
});
|
|
|
|
test('doctor check uses readFactsEmbeddingDim from the shared helper', () => {
|
|
expect(DOC_SRC).toMatch(/readFactsEmbeddingDim/);
|
|
});
|
|
|
|
test('doctor check uses buildFactsAlterRecipe (NOT a hand-rolled ALTER string)', () => {
|
|
expect(DOC_SRC).toMatch(/buildFactsAlterRecipe/);
|
|
});
|
|
});
|
|
|
|
describe('postgres-engine fact insert cast (T6, codex #20)', () => {
|
|
const PG_PATH = resolve(import.meta.dir, '..', 'src/core/postgres-engine.ts');
|
|
const PG_SRC = readFileSync(PG_PATH, 'utf-8');
|
|
|
|
test('insertFacts batch path uses cached castSuffix, NOT a hardcoded ::vector', () => {
|
|
expect(PG_SRC).toMatch(/resolveFactsEmbeddingCast/);
|
|
// The fixed call sites use `castSuffix`, not the literal `::vector`.
|
|
const literalHits = PG_SRC.match(/embedLit[^,)]*'::vector'/g);
|
|
// After T6 there should be zero remaining literal ::vector casts
|
|
// in the insertFacts paths. (Other ::vector references in pgvector
|
|
// SELECT helpers are unrelated; check only the embed-literal cast.)
|
|
expect(literalHits ?? []).toEqual([]);
|
|
});
|
|
|
|
test('cached cast suffix has a test-only reset hook for unit cases', () => {
|
|
expect(PG_SRC).toMatch(/__resetFactsEmbeddingCastCacheForTest/);
|
|
});
|
|
});
|