Files
gbrain/test/search-image-column.test.ts
T
8ab733471b v0.41.17.0 feat: --workers N on every bulk command + facts dim doctor parity (#1519)
* 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>
2026-05-26 18:29:03 -07:00

159 lines
5.2 KiB
TypeScript

// v0.27.1 follow-up: searchVector column routing — `embedding_image`
// path returns image rows only, default `embedding` path returns text/code
// rows only. Verifies the modality-filter contract that backs the
// `gbrain query --image <path>` flag.
import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
import { readContentChunksEmbeddingDim } from '../src/core/embedding-dim-check.ts';
let engine: PGLiteEngine;
/**
* Actual `content_chunks.embedding` column width at runtime. Probed
* after initSchema, NOT hardcoded — the brain inherits from
* `~/.gbrain/config.json` (locally) or `DEFAULT_EMBEDDING_DIMENSIONS`
* (CI fresh-install). Hardcoding 1536 or 1280 makes the test green
* on one and red on the other; the default model has flipped twice
* already (OpenAI 3-large=1536 → ZE zembed-1=1280 in v0.36+).
*/
let TEXT_DIM = 0;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
const probe = await readContentChunksEmbeddingDim(engine);
if (!probe.exists || probe.dims === null) {
throw new Error('content_chunks.embedding column missing after initSchema — test environment broken');
}
TEXT_DIM = probe.dims;
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
});
/**
* Build a fake text-column vector at the column's runtime dim. Reads
* `TEXT_DIM` populated in `beforeAll` from the actual column. Works
* on any default — 1280 (CI fresh-install) and 1536 (local dev with
* gbrain config from older default) both pass.
*/
function fakeTextDefault(seed: number): Float32Array {
const n = TEXT_DIM;
const out = new Float32Array(n);
for (let i = 0; i < n; i++) out[i] = (i + seed) / n;
return out;
}
function fakeImage1024(seed: number): Float32Array {
const out = new Float32Array(1024);
for (let i = 0; i < 1024; i++) out[i] = (i + seed) / 1024;
return out;
}
async function seedTextPage(slug: string, vec: Float32Array) {
await engine.putPage(slug, {
type: 'note',
title: slug,
compiled_truth: `body for ${slug}`,
timeline: '',
});
await engine.upsertChunks(slug, [
{
chunk_index: 0,
chunk_text: `body for ${slug}`,
chunk_source: 'compiled_truth',
embedding: vec,
modality: 'text',
},
]);
}
async function seedImagePage(slug: string, vec: Float32Array) {
await engine.putPage(slug, {
type: 'image',
page_kind: 'image',
title: slug,
compiled_truth: '',
timeline: '',
});
await engine.upsertChunks(slug, [
{
chunk_index: 0,
chunk_text: slug,
chunk_source: 'image_asset',
embedding_image: vec,
modality: 'image',
},
]);
}
describe('searchVector column routing (v0.27.1)', () => {
test('default path searches embedding column and returns text rows only', async () => {
await seedTextPage('notes/text-only', fakeTextDefault(1));
await seedImagePage('photos/img-only', fakeImage1024(1));
const out = await engine.searchVector(fakeTextDefault(1), { limit: 10 });
const slugs = out.map(r => r.slug);
expect(slugs).toContain('notes/text-only');
expect(slugs).not.toContain('photos/img-only');
});
test('embeddingColumn=embedding_image searches image column and returns image rows only', async () => {
await seedTextPage('notes/text-only', fakeTextDefault(2));
await seedImagePage('photos/img-only', fakeImage1024(2));
const out = await engine.searchVector(fakeImage1024(2), {
limit: 10,
embeddingColumn: 'embedding_image',
});
const slugs = out.map(r => r.slug);
expect(slugs).toContain('photos/img-only');
expect(slugs).not.toContain('notes/text-only');
});
test('image-column path scores by cosine and orders nearest first', async () => {
// Two image pages with different vectors; query nearest the second.
const vecA = fakeImage1024(0);
const vecB = new Float32Array(1024);
for (let i = 0; i < 1024; i++) vecB[i] = (i + 100) / 1024;
await seedImagePage('photos/a', vecA);
await seedImagePage('photos/b', vecB);
const hits = await engine.searchVector(vecB, {
limit: 5,
embeddingColumn: 'embedding_image',
});
expect(hits.length).toBeGreaterThanOrEqual(2);
expect(hits[0].slug).toBe('photos/b');
});
test('searchKeyword hides image rows by default (modality filter)', async () => {
await seedTextPage('notes/keyword', fakeTextDefault(3));
await seedImagePage('photos/keyword', fakeImage1024(3));
// Force image chunk_text to overlap with the text chunk's words so the
// FTS would otherwise match both rows.
await engine.upsertChunks('photos/keyword', [
{
chunk_index: 0,
chunk_text: 'body for notes/keyword',
chunk_source: 'image_asset',
embedding_image: fakeImage1024(3),
modality: 'image',
},
]);
const out = await engine.searchKeyword('body', { limit: 10 });
const slugs = out.map(r => r.slug);
expect(slugs).toContain('notes/keyword');
expect(slugs).not.toContain('photos/keyword');
});
});