mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 14:59:47 +00:00
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>
204 lines
7.5 KiB
TypeScript
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');
|
|
});
|
|
});
|