Files
gbrain/test/two-pass.test.ts
3381dd7658 fix(search): preserve email citation metadata across result paths (takeover of #2875)
Salvage of PR #2875 (which subsumes the base projection from #2873),
rebased onto current master with the release bookkeeping (VERSION /
package.json / CHANGELOG) dropped, plus one hot-path hardening fix.

Salvaged (verified on this head):
- project trusted message_id / thread_id / Message-ID-gated source_subject
  through keyword, chunk-keyword, CJK, and vector paths in BOTH engines
- preserve the citation DTO through alias injection, relational
  recall/fanout, two-pass hydration, vector fusion/reranking, and
  semantic-cache hits
- raw source_subject is never trusted; only allowlisted `subject` may
  supply it, and only when a nonblank Message-ID proves the page is an
  email; malformed/non-object frontmatter fails closed (no double-decode)
- source visibility / quarantine / deletion rechecked across indirect
  retrieval paths (alias hop, relational hydrate, two-pass expansion,
  graph walk, cache-hit gate)
- typed JSON cache scope keys (scalar/set/all) — injective encoding, no
  forged-key collisions; store-side write gate skips writeback when the
  page-generation clock advanced during the producing search
- KNOBS_HASH_VERSION 12 -> 13 so pre-projection cached DTOs miss instead
  of replaying the old shape

Fixed on top of the original head (the flagged hot-path defect):
- cacheScopeKey's forged-id rejection was evaluated inline at the cache
  lookup/store call sites inside hybridSearchCached, outside any catch —
  an invalid scope id broke the whole search instead of skipping the
  cache. The key is now computed once, fail-open: invalid scope =>
  cache skipped, search unaffected. Pinned by
  test/search/hybrid-cache-scope-failopen.serial.test.ts (fails on the
  original head, passes here).

Verified on this exact head: typecheck clean; 379 touched unit tests,
3 serial files (one process each), pglite cache-gate/source-isolation
e2e, and real-PostgreSQL engine-parity (26 pass) + source-routing —
all green. jsonb-pattern/params, key-files-current-state,
test-isolation, progress-to-stdout guards clean.

Closes #2962

Co-authored-by: amtagrwl <amtagrwl@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 15:07:56 -07:00

204 lines
7.5 KiB
TypeScript

/**
* v0.20.0 Cathedral II Layer 7 (A2) — two-pass retrieval tests.
*
* Validates:
* - expandAnchors no-op when walkDepth=0 and nearSymbol unset.
* - walkDepth=1 adds 1-hop neighbors with decayed scores.
* - walkDepth=2 adds 2-hop neighbors (capped).
* - nearSymbol anchors chunks by qualified name.
* - hybridSearch respects opts.walkDepth + opts.nearSymbol without
* breaking the default-off retrieval path.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { expandAnchors, hydrateChunks } from '../src/core/search/two-pass.ts';
describe('Layer 7 (A2) — expandAnchors', () => {
let engine: PGLiteEngine;
let chunkA: number;
let chunkB: number;
let chunkC: number;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
await engine.putPage('src-a-ts', {
type: 'code', page_kind: 'code',
title: 'src/a.ts (typescript)',
compiled_truth: 'export function a() { return b(); }',
timeline: '',
});
await engine.upsertChunks('src-a-ts', [{
chunk_index: 0,
chunk_text: 'export function a() { return b(); }',
chunk_source: 'compiled_truth',
language: 'typescript',
symbol_name: 'a', symbol_type: 'function',
symbol_name_qualified: 'a',
}]);
await engine.putPage('src-b-ts', {
type: 'code', page_kind: 'code',
title: 'src/b.ts (typescript)',
compiled_truth: 'export function b() { return c(); }',
timeline: '',
frontmatter: {
message_id: '<code-review@example.com>',
thread_id: 'thread-code-review',
subject: 'Code review notes',
},
});
await engine.upsertChunks('src-b-ts', [{
chunk_index: 0,
chunk_text: 'export function b() { return c(); }',
chunk_source: 'compiled_truth',
language: 'typescript',
symbol_name: 'b', symbol_type: 'function',
symbol_name_qualified: 'b',
}]);
await engine.putPage('src-c-ts', {
type: 'code', page_kind: 'code',
title: 'src/c.ts (typescript)',
compiled_truth: 'export function c() { return 1; }',
timeline: '',
});
await engine.upsertChunks('src-c-ts', [{
chunk_index: 0,
chunk_text: 'export function c() { return 1; }',
chunk_source: 'compiled_truth',
language: 'typescript',
symbol_name: 'c', symbol_type: 'function',
symbol_name_qualified: 'c',
}]);
const aChunks = await engine.getChunks('src-a-ts');
const bChunks = await engine.getChunks('src-b-ts');
const cChunks = await engine.getChunks('src-c-ts');
chunkA = aChunks[0]!.id;
chunkB = bChunks[0]!.id;
chunkC = cChunks[0]!.id;
// Edges: a → b, b → c (unresolved — code_edges_symbol path).
await engine.addCodeEdges([
{ from_chunk_id: chunkA, to_chunk_id: null,
from_symbol_qualified: 'a', to_symbol_qualified: 'b',
edge_type: 'calls' },
{ from_chunk_id: chunkB, to_chunk_id: null,
from_symbol_qualified: 'b', to_symbol_qualified: 'c',
edge_type: 'calls' },
{ from_chunk_id: chunkA, to_chunk_id: chunkB,
from_symbol_qualified: 'a', to_symbol_qualified: 'b',
edge_type: 'imports' },
]);
});
afterAll(async () => {
await engine.disconnect();
}, 30_000);
test('walkDepth=0 is a no-op (anchors only)', async () => {
const anchors = [{
slug: 'src-a-ts', page_id: 0, title: 'a', type: 'code',
chunk_text: '', chunk_source: 'compiled_truth', chunk_id: chunkA,
chunk_index: 0, score: 1.0, stale: false, source_id: 'default',
} as never];
const expanded = await expandAnchors(engine, anchors, { walkDepth: 0 });
expect(expanded.length).toBe(1);
expect(expanded[0]!.chunk_id).toBe(chunkA);
expect(expanded[0]!.hop).toBe(0);
});
test('walkDepth=1 expands to direct neighbors', async () => {
const anchors = [{
slug: 'src-a-ts', page_id: 0, title: 'a', type: 'code',
chunk_text: '', chunk_source: 'compiled_truth', chunk_id: chunkA,
chunk_index: 0, score: 1.0, stale: false, source_id: 'default',
} as never];
const expanded = await expandAnchors(engine, anchors, { walkDepth: 1 });
const ids = expanded.map(e => e.chunk_id);
expect(ids).toContain(chunkA); // anchor
expect(ids).toContain(chunkB); // 1-hop neighbor via calls edge
const neighbor = expanded.find(e => e.chunk_id === chunkB);
expect(neighbor!.hop).toBe(1);
// 1/(1+1) * 1.0 = 0.5
expect(neighbor!.score).toBeCloseTo(0.5, 2);
});
test('walkDepth=1 follows an incoming resolved edge to the opposite endpoint', async () => {
const anchors = [{
slug: 'src-b-ts', page_id: 0, title: 'b', type: 'code',
chunk_text: '', chunk_source: 'compiled_truth', chunk_id: chunkB,
chunk_index: 0, score: 1.0, stale: false, source_id: 'default',
} as never];
const expanded = await expandAnchors(engine, anchors, { walkDepth: 1 });
expect(expanded.map(e => e.chunk_id)).toContain(chunkA);
});
test('walkDepth=2 reaches grandchildren', async () => {
const anchors = [{
slug: 'src-a-ts', page_id: 0, title: 'a', type: 'code',
chunk_text: '', chunk_source: 'compiled_truth', chunk_id: chunkA,
chunk_index: 0, score: 1.0, stale: false, source_id: 'default',
} as never];
const expanded = await expandAnchors(engine, anchors, { walkDepth: 2 });
const ids = expanded.map(e => e.chunk_id);
expect(ids).toContain(chunkC); // 2-hop
const twoHop = expanded.find(e => e.chunk_id === chunkC);
expect(twoHop!.hop).toBe(2);
});
test('walkDepth clamps at 2 (even when caller passes 5)', async () => {
const anchors = [{
slug: 'src-a-ts', page_id: 0, title: 'a', type: 'code',
chunk_text: '', chunk_source: 'compiled_truth', chunk_id: chunkA,
chunk_index: 0, score: 1.0, stale: false, source_id: 'default',
} as never];
const expanded = await expandAnchors(engine, anchors, { walkDepth: 5 });
const maxHop = Math.max(...expanded.map(e => e.hop));
expect(maxHop).toBeLessThanOrEqual(2);
});
test('nearSymbol anchors chunks by qualified name', async () => {
const expanded = await expandAnchors(engine, [], {
walkDepth: 1,
nearSymbol: 'b',
});
const ids = expanded.map(e => e.chunk_id);
expect(ids).toContain(chunkB); // anchored via nearSymbol
expect(ids).toContain(chunkC); // 1-hop neighbor
});
test('hydrateChunks fetches SearchResult rows for chunk IDs', async () => {
const rows = await hydrateChunks(engine, [chunkB, chunkC]);
expect(rows.length).toBe(2);
const slugs = rows.map(r => r.slug);
expect(slugs).toContain('src-b-ts');
expect(slugs).toContain('src-c-ts');
const b = rows.find(r => r.slug === 'src-b-ts');
expect(b?.message_id).toBe('<code-review@example.com>');
expect(b?.thread_id).toBe('thread-code-review');
expect(b?.source_subject).toBe('Code review notes');
});
test('hydrateChunks with empty array returns []', async () => {
const rows = await hydrateChunks(engine, []);
expect(rows).toEqual([]);
});
});
describe('Layer 7 (A2) — query operation schema', () => {
test('query op exposes near_symbol + walk_depth params', async () => {
const { operations } = await import('../src/core/operations.ts');
const queryOp = operations.find(o => o.name === 'query');
expect(queryOp).toBeDefined();
expect(queryOp!.params.near_symbol).toBeDefined();
expect(queryOp!.params.walk_depth).toBeDefined();
expect(queryOp!.params.walk_depth!.type).toBe('number');
});
});