Files
gbrain/test/e2e/engine-parity.test.ts
T
SilverNineandClaude Fable 5 0621687276 fix(search): port the CJK keyword fallback to the Postgres engine (PGLite parity)
websearch_to_tsquery('english') can't tokenize CJK, so the keyword arm
of hybrid search degrades badly for Korean/Chinese/Japanese queries on
the Postgres engine — particle-suffixed Korean often matches nothing.
PGLite has had the hasCJK() -> ILIKE + occurrence-count-ranking fallback
since v0.32.7; the Postgres engine never got it.

Port _searchKeywordCJK to postgres-engine.ts at both FTS sites
(searchKeyword dedup path + searchKeywordChunks), keeping the host
engine's conventions (sql.begin + SET LOCAL statement_timeout, named
limit params, its extra-filter set including types/exclude_slugs).
scoreExpr, ILIKE ESCAPE discipline, empty-query guard, and the
dedup/chunk-grain split are copied from PGLite exactly.

Measured on a private production Korean corpus (74k chunks):
particle-suffixed queries returned 7 rows via english FTS vs 152 via
the fallback; a common two-word query 21 vs 847 — a 20-40x recall gap
this closes.

Tests: 6 source-guardrail tests in test/postgres-engine.test.ts +
3 Postgres-vs-PGLite parity e2e tests with synthetic Korean fixtures.
bun run verify 31/31.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 12:51:40 +09:00

765 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Engine Parity E2E
*
* Codex flagged that searchKeyword behavior differs structurally between
* the two engines (Postgres uses a CTE that ranks pages then picks best
* chunk; PGLite returns chunks directly). Without verification, source-aware
* ranking could pass on PGLite and silently fail on Postgres.
*
* Strategy: seed identical corpora into both engines, run identical queries,
* assert top-5 slug ordering matches.
*
* Gated by DATABASE_URL — skips gracefully if no real Postgres. Always runs
* the PGLite half so the seed/query path is at least exercised.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
import type { ChunkInput, SearchResult } from '../../src/core/types.ts';
import type { BrainEngine } from '../../src/core/engine.ts';
import { hasDatabase, setupDB, teardownDB, getEngine } from './helpers.ts';
const SKIP_PG = !hasDatabase();
const describeBoth = SKIP_PG ? describe.skip : describe;
function basisEmbedding(idx: number, dim = 1536): Float32Array {
const emb = new Float32Array(dim);
emb[idx % dim] = 1.0;
return emb;
}
interface SeedPage {
slug: string;
type: 'writing' | 'concept' | 'note' | 'person' | 'company';
title: string;
body: string;
embeddingDim: number;
}
const SEED_PAGES: SeedPage[] = [
{
slug: 'originals/talks/article-outline-fat-code',
type: 'writing',
title: 'Fat Code Thin Harness — Part 3',
body: 'fat code thin harness pattern part 3 production case studies',
embeddingDim: 7,
},
{
slug: 'concepts/fat-code-thin-harness',
type: 'concept',
title: 'Fat Code Thin Harness',
body: 'reusable concept fat code thin harness architecture',
embeddingDim: 14,
},
{
slug: 'openclaw/chat/2026-04-15',
type: 'note',
title: '2026-04-15 chat',
body:
'fat code thin harness fat code thin harness discussion went on at length, ' +
'fat code thin harness came up again and again, fat code thin harness fat code thin harness.',
embeddingDim: 8,
},
{
slug: 'openclaw/chat/2026-04-16',
type: 'note',
title: '2026-04-16 chat',
body:
'fat code thin harness once more, fat code thin harness fat code thin harness, ' +
'still talking about fat code thin harness fat code thin harness.',
embeddingDim: 9,
},
{
slug: 'people/example-founder',
type: 'person',
title: 'Example Founder',
body: 'example founder unrelated content for distraction',
embeddingDim: 50,
},
// v0.32.7 CJK branch (Postgres parity) fixtures — synthetic Korean text
// about a fictional "그리팅 서비스". Multi-hit page (3× 그리팅) must
// outrank the one-hit page under occurrence-count ranking on BOTH engines.
{
slug: 'concepts/greeting-service',
type: 'concept',
title: '그리팅 서비스 개요',
body: '그리팅 서비스는 가상의 인사 자동화 플랫폼입니다. 그리팅 봇이 아침마다 인사를 보냅니다. 오늘도 그리팅 팀은 새로운 인사말을 준비했습니다.',
embeddingDim: 33,
},
{
slug: 'notes/greeting-one-hit',
type: 'note',
title: '가상 메모',
body: '어제 회의에서 그리팅 이야기가 잠깐 나왔다. 나머지는 다른 주제였다.',
embeddingDim: 34,
},
];
async function seedEngine(eng: BrainEngine) {
for (const p of SEED_PAGES) {
await eng.putPage(p.slug, {
type: p.type,
title: p.title,
compiled_truth: p.body,
timeline: '',
});
const chunks: ChunkInput[] = [
{
chunk_index: 0,
chunk_text: p.body,
chunk_source: 'compiled_truth',
embedding: basisEmbedding(p.embeddingDim),
token_count: p.body.split(/\s+/).length,
},
];
await eng.upsertChunks(p.slug, chunks);
}
}
const QUERIES = [
'fat code thin harness',
'fat code thin harness part 3',
'fat code production',
];
describeBoth('Engine parity — Postgres vs PGLite', () => {
let pgEngine: BrainEngine;
let pgliteEngine: PGLiteEngine;
beforeAll(async () => {
pgEngine = await setupDB();
await seedEngine(pgEngine);
pgliteEngine = new PGLiteEngine();
await pgliteEngine.connect({});
await pgliteEngine.initSchema();
await seedEngine(pgliteEngine);
}, 90_000);
afterAll(async () => {
await pgliteEngine.disconnect();
await teardownDB();
}, 30_000);
for (const q of QUERIES) {
test(`searchKeyword: top-5 slugs match for "${q}"`, async () => {
const pgResults = await pgEngine.searchKeyword(q, { limit: 5 });
const pgliteResults = await pgliteEngine.searchKeyword(q, { limit: 5 });
const pgSlugs = pgResults.map((r: SearchResult) => r.slug);
const pgliteSlugs = pgliteResults.map((r: SearchResult) => r.slug);
// Top result MUST match (the swamp-resistance guarantee).
expect(pgSlugs[0]).toBe(pgliteSlugs[0]);
// Sets should match (allowing some ordering drift on lower-ranked
// results since FTS rank function differences between engines are
// out of scope for this fix).
expect(new Set(pgSlugs)).toEqual(new Set(pgliteSlugs));
});
}
// v0.32.7 CJK branch (Postgres parity): websearch_to_tsquery('english')
// can't tokenize CJK, so both engines route CJK queries to the ILIKE +
// occurrence-count fallback. These pin the fallback's cross-engine
// contract with purely synthetic Korean fixtures.
test('CJK parity: searchKeyword("그리팅") finds the same pages, same top rank', async () => {
const pgResults = await pgEngine.searchKeyword('그리팅', { limit: 5 });
const pgliteResults = await pgliteEngine.searchKeyword('그리팅', { limit: 5 });
// Without the fallback the Postgres engine returned [] here (empty
// english tsquery) while PGLite matched — the exact drift this pins.
expect(pgResults.length).toBeGreaterThan(0);
expect(pgliteResults.length).toBeGreaterThan(0);
// Occurrence-count ranking: 3-hit page outranks 1-hit page on BOTH.
expect(pgResults[0].slug).toBe('concepts/greeting-service');
expect(pgliteResults[0].slug).toBe('concepts/greeting-service');
const pgSlugs = pgResults.map((r: SearchResult) => r.slug);
const pgliteSlugs = pgliteResults.map((r: SearchResult) => r.slug);
expect(pgSlugs).toContain('notes/greeting-one-hit');
expect(new Set(pgSlugs)).toEqual(new Set(pgliteSlugs));
});
test('CJK parity: chunk-grain searchKeywordChunks matches across engines', async () => {
const pgResults = await pgEngine.searchKeywordChunks('그리팅', { limit: 5 });
const pgliteResults = await pgliteEngine.searchKeywordChunks('그리팅', { limit: 5 });
expect(pgResults.length).toBeGreaterThan(0);
expect(pgliteResults.length).toBeGreaterThan(0);
expect(pgResults[0].slug).toBe(pgliteResults[0].slug);
});
test('CJK parity: LIKE-meta-char escape — literal % query does not wildcard-match', async () => {
// After escapeLikePattern, ILIKE looks for a literal `%` character,
// which no seeded fixture contains. Both engines must return empty
// rather than treating % as a wildcard.
const pgResults = await pgEngine.searchKeyword('100% 그리팅');
const pgliteResults = await pgliteEngine.searchKeyword('100% 그리팅');
expect(pgResults.length).toBe(0);
expect(pgliteResults.length).toBe(0);
});
test('searchVector: top result matches between engines', async () => {
const queryVec = basisEmbedding(7); // article direction
const pgResults = await pgEngine.searchVector(queryVec, { limit: 5 });
const pgliteResults = await pgliteEngine.searchVector(queryVec, { limit: 5 });
expect(pgResults[0]?.slug).toBe(pgliteResults[0]?.slug);
});
test('hard-exclude is consistent across engines', async () => {
// Both engines should hide test/ pages by default; both should opt
// them back in via include_slug_prefixes.
await pgEngine.putPage('test/parity-fixture', {
type: 'note',
title: 'parity test fixture',
compiled_truth: 'parity test fixture content',
timeline: '',
});
await pgEngine.upsertChunks('test/parity-fixture', [{
chunk_index: 0,
chunk_text: 'parity test fixture content',
chunk_source: 'compiled_truth',
embedding: basisEmbedding(20),
token_count: 5,
}] satisfies ChunkInput[]);
await pgliteEngine.putPage('test/parity-fixture', {
type: 'note',
title: 'parity test fixture',
compiled_truth: 'parity test fixture content',
timeline: '',
});
await pgliteEngine.upsertChunks('test/parity-fixture', [{
chunk_index: 0,
chunk_text: 'parity test fixture content',
chunk_source: 'compiled_truth',
embedding: basisEmbedding(20),
token_count: 5,
}] satisfies ChunkInput[]);
const pgDefault = await pgEngine.searchKeyword('parity test fixture');
const pgliteDefault = await pgliteEngine.searchKeyword('parity test fixture');
expect(pgDefault.map((r: SearchResult) => r.slug)).not.toContain('test/parity-fixture');
expect(pgliteDefault.map((r: SearchResult) => r.slug)).not.toContain('test/parity-fixture');
const pgOptIn = await pgEngine.searchKeyword('parity test fixture', {
include_slug_prefixes: ['test/'],
});
const pgliteOptIn = await pgliteEngine.searchKeyword('parity test fixture', {
include_slug_prefixes: ['test/'],
});
expect(pgOptIn.map((r: SearchResult) => r.slug)).toContain('test/parity-fixture');
expect(pgliteOptIn.map((r: SearchResult) => r.slug)).toContain('test/parity-fixture');
});
test('detail=high produces a different ranking than default on at least one engine', async () => {
// Source-boost gates on `detail !== 'high'`. If the gate works on both
// engines, the ordering for `detail=high` should differ from default in
// any case where the swamp / curated pages have different raw scores.
//
// Postgres's CTE ranks pages then picks best chunk; ts_rank normalizes
// by doc length so chat pages don't always swamp at the page level.
// PGLite scores chunks directly — chat chunks beat article chunks on
// raw ts_rank. The two engines need different parity contracts here.
//
// Common assertion that holds on both: detail=high must include the
// chat pages in its result set (they're not filtered by detail), and
// the result set should not be identical to default-detail (the boost
// must be doing _something_ visible).
const pgDefault = await pgEngine.searchKeyword('fat code thin harness', { limit: 5 });
const pgHigh = await pgEngine.searchKeyword('fat code thin harness', { detail: 'high', limit: 5 });
const pgliteDefault = await pgliteEngine.searchKeyword('fat code thin harness', { limit: 5 });
const pgliteHigh = await pgliteEngine.searchKeyword('fat code thin harness', { detail: 'high', limit: 5 });
// Chat pages must be present in detail=high results on both engines.
expect(pgHigh.some((r: SearchResult) => r.slug.startsWith('openclaw/chat/'))).toBe(true);
expect(pgliteHigh.some((r: SearchResult) => r.slug.startsWith('openclaw/chat/'))).toBe(true);
// The boost must be doing something — at least one engine's ordering
// should change between default and detail=high.
const pgChanged = pgDefault.map((r: SearchResult) => r.slug).join(',') !== pgHigh.map((r: SearchResult) => r.slug).join(',');
const pgliteChanged = pgliteDefault.map((r: SearchResult) => r.slug).join(',') !== pgliteHigh.map((r: SearchResult) => r.slug).join(',');
expect(pgChanged || pgliteChanged).toBe(true);
});
// v0.39.3.0 T3 — provenance write+read parity (WARN-8 + CV5).
// Both engines must write the same 4 provenance columns (source_kind,
// source_uri, ingested_via, ingested_at) on putPage AND surface them
// on getPage. A drift here would mean `gbrain migrate --to supabase`
// silently loses half a user's provenance audit trail.
test('provenance columns: putPage writes + getPage returns identical shape on both engines', async () => {
const slug = 'wiki/provenance-parity';
const input = {
type: 'note' as const,
title: 'Provenance Parity Test',
compiled_truth: 'body',
timeline: '',
source_kind: 'capture-cli',
source_uri: 'file:///tmp/parity.md',
ingested_via: 'put_page',
};
await pgEngine.putPage(slug, input);
await pgliteEngine.putPage(slug, input);
const pgPage = await pgEngine.getPage(slug);
const pglitePage = await pgliteEngine.getPage(slug);
expect(pgPage).not.toBeNull();
expect(pglitePage).not.toBeNull();
// All 4 provenance fields must match across engines.
expect(pgPage!.source_kind).toBe('capture-cli');
expect(pglitePage!.source_kind).toBe('capture-cli');
expect(pgPage!.source_uri).toBe('file:///tmp/parity.md');
expect(pglitePage!.source_uri).toBe('file:///tmp/parity.md');
expect(pgPage!.ingested_via).toBe('put_page');
expect(pglitePage!.ingested_via).toBe('put_page');
// ingested_at is server-stamped; both engines must populate a Date
// (not Date drift across engines — the assertion is structural).
expect(pgPage!.ingested_at).toBeInstanceOf(Date);
expect(pglitePage!.ingested_at).toBeInstanceOf(Date);
});
test('provenance COALESCE-preserve UPDATE: parity on both engines (CV12)', async () => {
// First write with provenance.
const slug = 'wiki/provenance-preserve-parity';
await pgEngine.putPage(slug, {
type: 'note',
title: 'V1',
compiled_truth: 'body v1',
timeline: '',
source_kind: 'capture-cli',
ingested_via: 'put_page',
});
await pgliteEngine.putPage(slug, {
type: 'note',
title: 'V1',
compiled_truth: 'body v1',
timeline: '',
source_kind: 'capture-cli',
ingested_via: 'put_page',
});
// Second write WITHOUT provenance — both engines must preserve
// the first-write audit trail via COALESCE-preserve UPDATE.
await pgEngine.putPage(slug, {
type: 'note',
title: 'V2',
compiled_truth: 'body v2',
timeline: '',
});
await pgliteEngine.putPage(slug, {
type: 'note',
title: 'V2',
compiled_truth: 'body v2',
timeline: '',
});
const pgPage = await pgEngine.getPage(slug);
const pglitePage = await pgliteEngine.getPage(slug);
// Provenance preserved on BOTH engines (CV12 first-write-wins).
expect(pgPage!.source_kind).toBe('capture-cli');
expect(pglitePage!.source_kind).toBe('capture-cli');
expect(pgPage!.ingested_via).toBe('put_page');
expect(pglitePage!.ingested_via).toBe('put_page');
// Page title updated (proves the UPDATE actually fired).
expect(pgPage!.title).toBe('V2');
expect(pglitePage!.title).toBe('V2');
});
test('v0.41.19.0 deletePages parity: both engines return same confirmed-deleted slugs', async () => {
const realSlugs = ['wiki/dpp-1', 'wiki/dpp-2', 'wiki/dpp-3'];
for (const slug of realSlugs) {
await pgEngine.putPage(slug, {
type: 'note', title: slug, compiled_truth: 'body', timeline: '',
});
await pgliteEngine.putPage(slug, {
type: 'note', title: slug, compiled_truth: 'body', timeline: '',
});
}
// Mix real + ghost slugs. D6: only real ones come back.
const allSlugs = [...realSlugs, 'wiki/dpp-ghost-a', 'wiki/dpp-ghost-b'];
const pgDeleted = await pgEngine.deletePages(allSlugs, { sourceId: 'default' });
const pgliteDeleted = await pgliteEngine.deletePages(allSlugs, { sourceId: 'default' });
expect(pgDeleted.sort()).toEqual(realSlugs.sort());
expect(pgliteDeleted.sort()).toEqual(realSlugs.sort());
// Pages actually gone on both engines.
for (const slug of realSlugs) {
const pg = await pgEngine.getPage(slug);
const pglite = await pgliteEngine.getPage(slug);
expect(pg).toBeNull();
expect(pglite).toBeNull();
}
});
test('v114 (#1941) listLinkSources parity: same ordered provenance counts on both engines', async () => {
const mk = async (eng: BrainEngine) => {
for (const s of ['lsp-a', 'lsp-b', 'lsp-c']) {
await eng.putPage(s, { type: 'note', title: s, compiled_truth: 'b', timeline: '' });
}
// citation-graph:2, manual:1 — exercises count DESC + the kebab regex.
await eng.addLink('lsp-a', 'lsp-b', '', 'cites', 'citation-graph');
await eng.addLink('lsp-a', 'lsp-c', '', 'cites', 'citation-graph');
await eng.addLink('lsp-b', 'lsp-c', '', 'rel', 'manual');
};
await mk(pgEngine);
await mk(pgliteEngine);
const pg = await pgEngine.listLinkSources({ sourceId: 'default' });
const pglite = await pgliteEngine.listLinkSources({ sourceId: 'default' });
const norm = (rows: { link_source: string | null; count: number }[]) =>
rows.filter(r => r.link_source === 'citation-graph' || r.link_source === 'manual');
expect(norm(pg)).toEqual(norm(pglite));
// citation-graph (2) must order before manual (1) on both engines.
const cgIdx = pg.findIndex(r => r.link_source === 'citation-graph');
const mIdx = pg.findIndex(r => r.link_source === 'manual');
expect(cgIdx).toBeLessThan(mIdx);
});
test('v0.41.19.0 resolveSlugsByPaths parity: same Map on both engines', async () => {
const seedSql = `
INSERT INTO pages (source_id, slug, source_path, type, title, compiled_truth, timeline, frontmatter)
VALUES ('default', $1, $2, 'note', 't', 'b', '', '{}'::jsonb)
ON CONFLICT (source_id, slug) DO UPDATE SET source_path = EXCLUDED.source_path
`;
await pgEngine.executeRaw(seedSql, ['wiki/rsp-1', 'wiki/rsp-1.md']);
await pgEngine.executeRaw(seedSql, ['wiki/rsp-2', 'wiki/rsp-2.md']);
await pgliteEngine.executeRaw(seedSql, ['wiki/rsp-1', 'wiki/rsp-1.md']);
await pgliteEngine.executeRaw(seedSql, ['wiki/rsp-2', 'wiki/rsp-2.md']);
const paths = ['wiki/rsp-1.md', 'wiki/rsp-2.md', 'wiki/rsp-missing.md'];
const pgMap = await pgEngine.resolveSlugsByPaths(paths, { sourceId: 'default' });
const pgliteMap = await pgliteEngine.resolveSlugsByPaths(paths, { sourceId: 'default' });
expect(pgMap.size).toBe(2);
expect(pgliteMap.size).toBe(2);
expect(pgMap.get('wiki/rsp-1.md')).toBe('wiki/rsp-1');
expect(pgliteMap.get('wiki/rsp-1.md')).toBe('wiki/rsp-1');
expect(pgMap.get('wiki/rsp-2.md')).toBe('wiki/rsp-2');
expect(pgliteMap.get('wiki/rsp-2.md')).toBe('wiki/rsp-2');
expect(pgMap.get('wiki/rsp-missing.md')).toBeUndefined();
expect(pgliteMap.get('wiki/rsp-missing.md')).toBeUndefined();
});
// v0.41.29.0 — findOrphanPages source scoping parity. Real Postgres
// coverage for the postgres.js `sql` scalar fragment + `= ANY(${arr}::text[])`
// array binding (a documented footgun class — the jsonb double-encode saga).
// PGLite logic is pinned in test/orphans-source-scope.test.ts; this asserts
// the Postgres SQL produces the same scoped sets. Cross-source inbound
// (src-b → src-a) must NOT make the target an orphan of src-a (A2).
test('v0.41.29.0 findOrphanPages source scoping parity (scalar + federated)', async () => {
const srcSql = `INSERT INTO sources (id, name, config) VALUES ($1, $1, '{}'::jsonb) ON CONFLICT DO NOTHING`;
const pageSql = `
INSERT INTO pages (source_id, slug, type, title, compiled_truth, timeline, frontmatter)
VALUES ($1, $2, 'person', 't', 'b', '', '{}'::jsonb)
ON CONFLICT (source_id, slug) DO NOTHING
`;
for (const eng of [pgEngine, pgliteEngine]) {
await eng.executeRaw(srcSql, ['orphan-src-a']);
await eng.executeRaw(srcSql, ['orphan-src-b']);
await eng.executeRaw(pageSql, ['orphan-src-a', 'people/op-orphan-a']);
await eng.executeRaw(pageSql, ['orphan-src-a', 'people/op-target-a']);
await eng.executeRaw(pageSql, ['orphan-src-b', 'people/op-linker-b']);
// Cross-source inbound: src-b page → src-a target (A2).
await eng.addLink(
'people/op-linker-b', 'people/op-target-a', '', 'mentions', 'markdown',
undefined, undefined, { fromSourceId: 'orphan-src-b', toSourceId: 'orphan-src-a' },
);
}
const scoped = async (eng: BrainEngine, opts: { sourceId?: string; sourceIds?: string[] }) =>
(await eng.findOrphanPages(opts)).map(r => r.slug).filter(s => s.startsWith('people/op-')).sort();
// Scalar scope to src-a: op-orphan-a is an orphan; op-target-a is saved
// by the cross-source inbound (A2). Parity on both engines.
const pgA = await scoped(pgEngine, { sourceId: 'orphan-src-a' });
const pgliteA = await scoped(pgliteEngine, { sourceId: 'orphan-src-a' });
expect(pgA).toEqual(['people/op-orphan-a']);
expect(pgliteA).toEqual(pgA);
// Scalar scope to src-b.
const pgB = await scoped(pgEngine, { sourceId: 'orphan-src-b' });
const pgliteB = await scoped(pgliteEngine, { sourceId: 'orphan-src-b' });
expect(pgB).toEqual(['people/op-linker-b']);
expect(pgliteB).toEqual(pgB);
// Federated array scope (= ANY binding) → union.
const pgFed = await scoped(pgEngine, { sourceIds: ['orphan-src-a', 'orphan-src-b'] });
const pgliteFed = await scoped(pgliteEngine, { sourceIds: ['orphan-src-a', 'orphan-src-b'] });
expect(pgFed).toEqual(['people/op-linker-b', 'people/op-orphan-a']);
expect(pgliteFed).toEqual(pgFed);
});
// v0.42.7 (#1696): stale-page extraction watermark parity. Isolated under a
// dedicated source so other tests' mutations don't perturb the counts.
test('stale-page extraction methods: Postgres ↔ PGLite parity', async () => {
const SRC = 'stale-parity';
const VER = '2026-05-31T00:00:00Z';
for (const eng of [pgEngine, pgliteEngine]) {
await eng.executeRaw(`INSERT INTO sources (id, name, config) VALUES ($1, 'Stale Parity', '{}'::jsonb) ON CONFLICT DO NOTHING`, [SRC]);
await eng.executeRaw(
`INSERT INTO pages (slug, source_id, type, title, compiled_truth, timeline, frontmatter, content_hash, created_at, updated_at)
SELECT 'sp/' || g, $1, 'concept', 'SP' || g, 'body ' || g, '', '{}'::jsonb, 'sph' || g, now(), now()
FROM generate_series(1, 3) g`,
[SRC],
);
}
// NULL arm: all 3 stale on both engines.
expect(await pgEngine.countStalePagesForExtraction({ sourceId: SRC })).toBe(3);
expect(await pgliteEngine.countStalePagesForExtraction({ sourceId: SRC })).toBe(3);
// listStalePagesForExtraction: same slugs + content columns populated.
const pgList = (await pgEngine.listStalePagesForExtraction({ batchSize: 10, sourceId: SRC })).map(r => r.slug).sort();
const plList = (await pgliteEngine.listStalePagesForExtraction({ batchSize: 10, sourceId: SRC })).map(r => r.slug).sort();
expect(pgList).toEqual(['sp/1', 'sp/2', 'sp/3']);
expect(plList).toEqual(pgList);
const pgRow = (await pgEngine.listStalePagesForExtraction({ batchSize: 1, sourceId: SRC }))[0];
expect(pgRow.compiled_truth).toBeTruthy();
expect(pgRow.updated_at).toBeInstanceOf(Date);
// markPagesExtractedBatch: stamp one → count drops to 2 on both.
const stampAt = new Date().toISOString();
await pgEngine.markPagesExtractedBatch([{ slug: 'sp/1', source_id: SRC }], stampAt);
await pgliteEngine.markPagesExtractedBatch([{ slug: 'sp/1', source_id: SRC }], stampAt);
expect(await pgEngine.countStalePagesForExtraction({ sourceId: SRC })).toBe(2);
expect(await pgliteEngine.countStalePagesForExtraction({ sourceId: SRC })).toBe(2);
// version arm: stamp sp/2 old + set updated_at old (isolate version arm) →
// flagged only when versionTs is passed. Parity on both engines.
for (const eng of [pgEngine, pgliteEngine]) {
await eng.markPagesExtractedBatch([{ slug: 'sp/2', source_id: SRC }], '2000-01-01T00:00:00Z');
await eng.executeRaw(`UPDATE pages SET updated_at = '2000-01-01T00:00:00Z' WHERE slug = 'sp/2' AND source_id = $1`, [SRC]);
}
// Without versionTs: sp/2 not stale (stamp == updated, not NULL). sp/3 still NULL-stale.
expect(await pgEngine.countStalePagesForExtraction({ sourceId: SRC })).toBe(1);
expect(await pgliteEngine.countStalePagesForExtraction({ sourceId: SRC })).toBe(1);
// With versionTs: sp/2's old stamp (< VER) re-flags it → 2 stale.
expect(await pgEngine.countStalePagesForExtraction({ sourceId: SRC, versionTs: VER })).toBe(2);
expect(await pgliteEngine.countStalePagesForExtraction({ sourceId: SRC, versionTs: VER })).toBe(2);
// edited-since arm: stamp sp/1 in the recent past, updated_at slightly after →
// re-flagged on both engines (updated_at > links_extracted_at).
for (const eng of [pgEngine, pgliteEngine]) {
await eng.executeRaw(
`UPDATE pages SET links_extracted_at = now() - interval '2 hours', updated_at = now() - interval '1 hour' WHERE slug = 'sp/1' AND source_id = $1`,
[SRC],
);
}
expect(await pgEngine.countStalePagesForExtraction({ sourceId: SRC })).toBe(2); // sp/1 (edited) + sp/3 (NULL)
expect(await pgliteEngine.countStalePagesForExtraction({ sourceId: SRC })).toBe(2);
});
test('v0.41.39 listEnrichCandidates parity (thin filter + source-aware inbound + order)', async () => {
const stub = 'Stub page.';
const pageSql = `
INSERT INTO pages (source_id, slug, type, title, compiled_truth, timeline, frontmatter)
VALUES ('default', $1, $2, $3, $4, '', '{}'::jsonb)
ON CONFLICT (source_id, slug) DO NOTHING
`;
for (const eng of [pgEngine, pgliteEngine]) {
// Two thin people (ec-alice ← 2 inbound, ec-bob ← 1), one thin company
// (ec-widget ← 0), one long page (must be excluded by the thin filter).
await eng.executeRaw(pageSql, ['ep/ec-alice', 'person', 'EC Alice', stub]);
await eng.executeRaw(pageSql, ['ep/ec-bob', 'person', 'EC Bob', stub]);
await eng.executeRaw(pageSql, ['companies/ec-widget', 'company', 'EC Widget', stub]);
await eng.executeRaw(pageSql, ['ep/ec-long', 'person', 'EC Long', 'x'.repeat(900)]);
// Linker pages + inbound links (link_source NULL → counted).
await eng.executeRaw(pageSql, ['ep/ec-l1', 'note', 'L1', 'links']);
await eng.executeRaw(pageSql, ['ep/ec-l2', 'note', 'L2', 'links']);
await eng.executeRaw(pageSql, ['ep/ec-l3', 'note', 'L3', 'links']);
await eng.addLink('ep/ec-l1', 'ep/ec-alice', 'ctx a1');
await eng.addLink('ep/ec-l2', 'ep/ec-alice', 'ctx a2');
await eng.addLink('ep/ec-l3', 'ep/ec-bob', 'ctx b1');
}
const run = async (eng: BrainEngine) =>
(await eng.listEnrichCandidates({
types: ['person', 'company'],
thinThreshold: 400,
order: 'inbound-links',
limit: 10,
sourceId: 'default',
})).filter((c) => c.slug.startsWith('ep/') || c.slug === 'companies/ec-widget');
const pg = await run(pgEngine);
const pglite = await run(pgliteEngine);
const shape = (rows: typeof pg) => rows.map((r) => `${r.slug}:${r.inbound_count}:${r.body_len}`);
expect(shape(pg)).toEqual(shape(pglite));
// Concrete contract: long page excluded; ordering alice(2) > bob(1) > widget(0).
const slugs = pg.map((r) => r.slug);
expect(slugs).not.toContain('ep/ec-long');
expect(slugs.indexOf('ep/ec-alice')).toBeLessThan(slugs.indexOf('ep/ec-bob'));
expect(slugs.indexOf('ep/ec-bob')).toBeLessThan(slugs.indexOf('companies/ec-widget'));
expect(pg.find((r) => r.slug === 'ep/ec-alice')!.inbound_count).toBe(2);
});
});
// ── relationalFanout parity (v0.43) ─────────────────────────────────────
async function seedRelational(eng: BrainEngine) {
const pages: Array<[string, 'company' | 'person']> = [
['companies/ep-widget', 'company'],
['companies/ep-other', 'company'],
['people/ep-inv-a', 'person'],
['people/ep-inv-b', 'person'],
['people/ep-emp-c', 'person'],
['people/ep-mentioner', 'person'],
];
for (const [slug, type] of pages) {
await eng.putPage(slug, { type, title: slug, compiled_truth: `${slug} body`, timeline: '' });
}
await eng.upsertChunks('people/ep-inv-b', [{
chunk_index: 0, chunk_text: 'b', chunk_source: 'compiled_truth',
embedding: basisEmbedding(2), token_count: 1,
}] satisfies ChunkInput[]);
await eng.addLink('people/ep-inv-a', 'companies/ep-widget', '', 'invested_in', 'manual');
await eng.addLink('people/ep-inv-b', 'companies/ep-widget', '', 'invested_in', 'manual');
await eng.addLink('people/ep-emp-c', 'companies/ep-widget', '', 'works_at', 'manual');
await eng.addLink('people/ep-mentioner', 'companies/ep-widget', '', 'mentions', 'mentions');
await eng.addLink('people/ep-inv-a', 'companies/ep-other', '', 'invested_in', 'manual');
}
describeBoth('Engine parity — relationalFanout', () => {
let pgEngine: BrainEngine;
let pgliteEngine: PGLiteEngine;
beforeAll(async () => {
pgEngine = await setupDB();
await seedRelational(pgEngine);
pgliteEngine = new PGLiteEngine();
await pgliteEngine.connect({});
await pgliteEngine.initSchema();
await seedRelational(pgliteEngine);
}, 90_000);
afterAll(async () => {
await pgliteEngine.disconnect();
await teardownDB();
}, 30_000);
const shape = (rows: Awaited<ReturnType<BrainEngine['relationalFanout']>>) =>
// canonical_chunk_id is a serial id — its absolute value diverges between a
// fresh PGLite engine and a shared Postgres DB whose content_chunks sequence
// advanced earlier (setupDB TRUNCATEs without RESTART IDENTITY). Compare its
// PRESENCE, not the exact id, so the parity check verifies graph structure +
// canonical-chunk resolution without depending on cross-engine sequence state.
rows.map(r => `${r.source_id}:${r.slug}:${r.hop}:${r.edge_count}:${r.via_link_types.join(',')}:${r.path.join('>')}:${r.canonical_chunk_id != null ? 'set' : 'null'}`);
test('typed-edge fan-out is identical across engines', async () => {
const opts = { direction: 'in' as const, linkTypes: ['invested_in'] };
const pg = await pgEngine.relationalFanout(['companies/ep-widget'], opts);
const pglite = await pgliteEngine.relationalFanout(['companies/ep-widget'], opts);
expect(shape(pg)).toEqual(shape(pglite));
expect(pg.map(r => r.slug).sort()).toEqual(['people/ep-inv-a', 'people/ep-inv-b']);
});
test('type-agnostic + mentions-exclusion identical across engines', async () => {
const pg = await pgEngine.relationalFanout(['companies/ep-widget'], { direction: 'in' });
const pglite = await pgliteEngine.relationalFanout(['companies/ep-widget'], { direction: 'in' });
expect(shape(pg)).toEqual(shape(pglite));
expect(pg.map(r => r.slug)).not.toContain('people/ep-mentioner');
});
test('connects (multi-seed, both) identical across engines', async () => {
const seeds = ['companies/ep-widget', 'companies/ep-other'];
const pg = await pgEngine.relationalFanout(seeds, { direction: 'both' });
const pglite = await pgliteEngine.relationalFanout(seeds, { direction: 'both' });
expect(shape(pg)).toEqual(shape(pglite));
});
});
// #2200 — federated sourceIds[] on the secondary-fetch reads must behave
// identically on both engines (a drift would mean a federated MCP client sees
// different tags/links/timeline after `gbrain migrate --to supabase`).
async function seedFederated(eng: BrainEngine) {
await eng.executeRaw(`INSERT INTO sources (id, name, local_path) VALUES ('beta', 'beta', '/tmp/beta') ON CONFLICT (id) DO NOTHING`);
await eng.putPage('fed/doc', { type: 'note', title: 'Fed doc', compiled_truth: 'b', timeline: '' }, { sourceId: 'beta' });
await eng.putPage('fed/target', { type: 'note', title: 'Fed target', compiled_truth: 'b', timeline: '' }, { sourceId: 'beta' });
await eng.putPage('fed/doc', { type: 'note', title: 'Default decoy', compiled_truth: 'd', timeline: '' }, { sourceId: 'default' });
await eng.putPage('fed/outside', { type: 'note', title: 'Outside', compiled_truth: 'd', timeline: '' }, { sourceId: 'default' });
await eng.addTag('fed/doc', 'beta-tag', { sourceId: 'beta' });
await eng.addTag('fed/doc', 'default-decoy-tag', { sourceId: 'default' });
await eng.addLink('fed/doc', 'fed/target', 'in', 'cites', 'markdown', undefined, undefined, { fromSourceId: 'beta', toSourceId: 'beta' });
await eng.addLink('fed/doc', 'fed/outside', 'leak', 'cites', 'markdown', undefined, undefined, { fromSourceId: 'beta', toSourceId: 'default' });
await eng.addLink('fed/target', 'fed/doc', 'inback', 'cites', 'markdown', undefined, undefined, { fromSourceId: 'beta', toSourceId: 'beta' });
await eng.addLink('fed/outside', 'fed/doc', 'leakback', 'cites', 'markdown', undefined, undefined, { fromSourceId: 'default', toSourceId: 'beta' });
// F1: in-grant edge authored by an out-of-grant origin — origin_slug must null out.
await eng.addLink('fed/doc', 'fed/target', 'originleak', 'mentions', 'frontmatter', 'fed/outside', 'related', { fromSourceId: 'beta', toSourceId: 'beta', originSourceId: 'default' });
await eng.addTimelineEntry('fed/doc', { date: '2026-02-02', source: 't', summary: 'fed event', detail: 'd' }, { sourceId: 'beta' });
// Second-dated entry so the after/before fragment paths (D5A Postgres refactor) are exercised.
await eng.addTimelineEntry('fed/doc', { date: '2026-08-08', source: 't', summary: 'late event', detail: 'd' }, { sourceId: 'beta' });
}
describeBoth('Engine parity — federated sourceIds[] secondary reads (#2200)', () => {
let pgEngine: BrainEngine;
let pgliteEngine: PGLiteEngine;
const grant = { sourceIds: ['beta'] };
beforeAll(async () => {
pgEngine = await setupDB();
await seedFederated(pgEngine);
pgliteEngine = new PGLiteEngine();
await pgliteEngine.connect({});
await pgliteEngine.initSchema();
await seedFederated(pgliteEngine);
}, 90_000);
afterAll(async () => {
await pgliteEngine.disconnect();
await teardownDB();
}, 30_000);
test('getTags identical under sourceIds[]', async () => {
const pg = (await pgEngine.getTags('fed/doc', grant)).sort();
const pglite = (await pgliteEngine.getTags('fed/doc', grant)).sort();
expect(pg).toEqual(pglite);
expect(pg).toEqual(['beta-tag']); // default decoy excluded
});
test('getLinks identical under sourceIds[] (all three endpoints scoped)', async () => {
const pg = (await pgEngine.getLinks('fed/doc', grant)).map(l => l.to_slug).sort();
const pglite = (await pgliteEngine.getLinks('fed/doc', grant)).map(l => l.to_slug).sort();
expect(pg).toEqual(pglite);
expect([...new Set(pg)]).toEqual(['fed/target']); // far-endpoint 'fed/outside' excluded
// F1: origin_slug nulled identically on both engines when origin is out-of-grant.
const pgOrigins = (await pgEngine.getLinks('fed/doc', grant)).map(l => l.origin_slug ?? null);
const pgliteOrigins = (await pgliteEngine.getLinks('fed/doc', grant)).map(l => l.origin_slug ?? null);
expect(pgOrigins.sort()).toEqual(pgliteOrigins.sort());
expect(pgOrigins).not.toContain('fed/outside');
});
test('getBacklinks identical under sourceIds[] (both endpoints scoped)', async () => {
const pg = (await pgEngine.getBacklinks('fed/doc', grant)).map(l => l.from_slug).sort();
const pglite = (await pgliteEngine.getBacklinks('fed/doc', grant)).map(l => l.from_slug).sort();
expect(pg).toEqual(pglite);
expect(pg).toEqual(['fed/target']);
});
test('getTimeline identical under sourceIds[]', async () => {
const pg = (await pgEngine.getTimeline('fed/doc', grant)).map(e => e.summary).sort();
const pglite = (await pgliteEngine.getTimeline('fed/doc', grant)).map(e => e.summary).sort();
expect(pg).toEqual(pglite);
expect(pg).toEqual(['fed event', 'late event']);
});
// Pins the D5A Postgres fragment refactor: after/before/both window paths must
// match PGLite under a federated grant (the 8-branch→composed-WHERE rewrite).
test('getTimeline date-window fragments identical across engines (D5A regression guard)', async () => {
for (const win of [{ after: '2026-05-01' }, { before: '2026-05-01' }, { after: '2026-01-01', before: '2026-12-31' }]) {
const opts = { ...grant, ...win };
const pg = (await pgEngine.getTimeline('fed/doc', opts)).map(e => e.summary).sort();
const pglite = (await pgliteEngine.getTimeline('fed/doc', opts)).map(e => e.summary).sort();
expect(pg).toEqual(pglite);
}
});
});