mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 06:23:01 +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>
304 lines
12 KiB
TypeScript
304 lines
12 KiB
TypeScript
/**
|
|
* Regression test for [CDX-4] cross-mode cache contamination.
|
|
*
|
|
* Before v0.32.3 (PR #897 as merged), the query_cache primary key was
|
|
* sha256(source_id::query_text) — a tokenmax search (expansion=on, limit=50)
|
|
* would populate a row that a subsequent conservative call (no expansion,
|
|
* limit=10) read back, serving the wrong-shape results.
|
|
*
|
|
* After v0.32.3:
|
|
* - cacheRowId(query, source, knobsHash) — knobsHash is part of the PK
|
|
* - SemanticQueryCache.lookup({knobsHash}) filters WHERE knobs_hash = $
|
|
* - SemanticQueryCache.store({knobsHash}) writes the resolved hash
|
|
*
|
|
* This test exercises the cache class directly on a fresh PGLite brain
|
|
* to verify cross-mode writes don't collide.
|
|
*/
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import { SemanticQueryCache, cacheRowId } from '../src/core/search/query-cache.ts';
|
|
import type { SearchResult } from '../src/core/types.ts';
|
|
import { knobsHash, resolveSearchMode } from '../src/core/search/mode.ts';
|
|
import { resolveHardExcludes } from '../src/core/search/source-boost.ts';
|
|
import { configureGateway, resetGateway } from '../src/core/ai/gateway.ts';
|
|
|
|
let engine: PGLiteEngine;
|
|
let visiblePageId: number;
|
|
|
|
const conservativeHash = knobsHash(resolveSearchMode({ mode: 'conservative' }));
|
|
const balancedHash = knobsHash(resolveSearchMode({ mode: 'balanced' }));
|
|
const tokenmaxHash = knobsHash(resolveSearchMode({ mode: 'tokenmax' }));
|
|
|
|
beforeAll(async () => {
|
|
// v0.36.2.0: DEFAULT_EMBEDDING_DIMENSIONS flipped to 1280 (ZE Matryoshka).
|
|
// The makeEmbedding fixture below emits 1536-dim unit vectors. If we let
|
|
// initSchema() inherit the default, query_cache.embedding gets sized at
|
|
// halfvec(1280) and the inserts throw "expected 1280 dimensions, not 1536".
|
|
// Pin the gateway to 1536d so this file is hermetic regardless of
|
|
// gateway state from other tests in the shard. Pattern matches
|
|
// test/consolidate-valid-until.test.ts.
|
|
resetGateway();
|
|
configureGateway({
|
|
embedding_model: 'openai:text-embedding-3-large',
|
|
embedding_dimensions: 1536,
|
|
env: { OPENAI_API_KEY: 'sk-fake' },
|
|
});
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
const page = await engine.putPage('cache/knobs-visible-fixture', {
|
|
type: 'note',
|
|
title: 'Knobs cache fixture',
|
|
compiled_truth: 'visible knobs cache fixture',
|
|
timeline: '',
|
|
frontmatter: {},
|
|
});
|
|
visiblePageId = page.id;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
resetGateway();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
// Clear cache before each test so writes start from empty state.
|
|
await engine.executeRaw('DELETE FROM query_cache');
|
|
});
|
|
|
|
const makeEmbedding = (seed: number): Float32Array => {
|
|
const arr = new Float32Array(1536);
|
|
for (let i = 0; i < 1536; i++) {
|
|
arr[i] = Math.sin(seed + i * 0.001);
|
|
}
|
|
// Normalize to unit length so cosine similarity is well-defined.
|
|
let norm = 0;
|
|
for (let i = 0; i < 1536; i++) norm += arr[i] * arr[i];
|
|
norm = Math.sqrt(norm);
|
|
if (norm > 0) for (let i = 0; i < 1536; i++) arr[i] /= norm;
|
|
return arr;
|
|
};
|
|
|
|
const makeResults = (label: string, n: number): SearchResult[] =>
|
|
Array.from({ length: n }, (_, i) => ({
|
|
slug: `${label}/result-${i}`,
|
|
title: `${label} result ${i}`,
|
|
chunk_text: `chunk-${label}-${i}`,
|
|
chunk_id: (i + 1) * 1000,
|
|
score: 1 / (i + 1),
|
|
chunk_index: i,
|
|
type: 'note' as const,
|
|
chunk_source: 'compiled_truth' as const,
|
|
page_id: visiblePageId,
|
|
stale: false,
|
|
}));
|
|
|
|
async function storeCurrent(
|
|
cache: SemanticQueryCache,
|
|
...args: Parameters<SemanticQueryCache['store']>
|
|
): Promise<void> {
|
|
const [queryText, queryEmbedding, results, meta, opts = {}] = args;
|
|
const rows = await engine.executeRaw<{ v: number }>(
|
|
`SELECT COALESCE((SELECT last_value FROM page_generation_clock_seq), 0)::bigint AS v`,
|
|
);
|
|
await cache.store(queryText, queryEmbedding, results, meta, {
|
|
...opts,
|
|
maxGenerationAtSearchStart:
|
|
opts.maxGenerationAtSearchStart ?? Number(rows[0]?.v ?? 0),
|
|
});
|
|
}
|
|
|
|
describe('cacheRowId is bifurcated by knobsHash', () => {
|
|
test('same (query, source) but different knobs → different row IDs', () => {
|
|
const id1 = cacheRowId('what is the meaning of life', 'default', conservativeHash);
|
|
const id2 = cacheRowId('what is the meaning of life', 'default', tokenmaxHash);
|
|
expect(id1).not.toBe(id2);
|
|
});
|
|
|
|
test('same (query, source, knobs) → same row ID (idempotent)', () => {
|
|
const id1 = cacheRowId('what is the meaning of life', 'default', balancedHash);
|
|
const id2 = cacheRowId('what is the meaning of life', 'default', balancedHash);
|
|
expect(id1).toBe(id2);
|
|
});
|
|
|
|
test('empty knobsHash still produces a valid ID (test-fixture compatibility)', () => {
|
|
const id = cacheRowId('q', 'default', '');
|
|
expect(id).toMatch(/^[0-9a-f]{32}$/);
|
|
});
|
|
|
|
test('all three mode hashes are distinct', () => {
|
|
expect(conservativeHash).not.toBe(balancedHash);
|
|
expect(balancedHash).not.toBe(tokenmaxHash);
|
|
expect(conservativeHash).not.toBe(tokenmaxHash);
|
|
});
|
|
});
|
|
|
|
describe('SemanticQueryCache cross-mode isolation (CDX-4 hotfix)', () => {
|
|
test('tokenmax write does NOT contaminate conservative lookup', async () => {
|
|
const cache = new SemanticQueryCache(engine);
|
|
const emb = makeEmbedding(1);
|
|
const tokenmaxResults = makeResults('tokenmax', 50);
|
|
|
|
// Write under tokenmax knobs.
|
|
await storeCurrent(cache, 'what is the meaning of life', emb, tokenmaxResults, {
|
|
vector_enabled: true,
|
|
detail_resolved: null,
|
|
expansion_applied: true,
|
|
}, { knobsHash: tokenmaxHash });
|
|
|
|
// Lookup under conservative knobs with the same embedding → MISS.
|
|
const conservativeHit = await cache.lookup(emb, { knobsHash: conservativeHash });
|
|
expect(conservativeHit.hit).toBe(false);
|
|
|
|
// Lookup under tokenmax knobs with the same embedding → HIT.
|
|
const tokenmaxHit = await cache.lookup(emb, { knobsHash: tokenmaxHash });
|
|
expect(tokenmaxHit.hit).toBe(true);
|
|
expect(tokenmaxHit.results?.length).toBe(50);
|
|
expect(tokenmaxHit.results?.[0].slug).toBe('tokenmax/result-0');
|
|
});
|
|
|
|
test('three modes coexist as distinct rows for the same query', async () => {
|
|
const cache = new SemanticQueryCache(engine);
|
|
const emb = makeEmbedding(2);
|
|
|
|
await storeCurrent(cache, 'q', emb, makeResults('conservative', 10), {
|
|
vector_enabled: true, detail_resolved: null, expansion_applied: false,
|
|
}, { knobsHash: conservativeHash });
|
|
await storeCurrent(cache, 'q', emb, makeResults('balanced', 25), {
|
|
vector_enabled: true, detail_resolved: null, expansion_applied: false,
|
|
}, { knobsHash: balancedHash });
|
|
await storeCurrent(cache, 'q', emb, makeResults('tokenmax', 50), {
|
|
vector_enabled: true, detail_resolved: null, expansion_applied: true,
|
|
}, { knobsHash: tokenmaxHash });
|
|
|
|
const rows = await engine.executeRaw<{ n: number }>(
|
|
`SELECT COUNT(*)::int AS n FROM query_cache WHERE query_text = 'q'`,
|
|
);
|
|
expect(rows[0].n).toBe(3);
|
|
|
|
const cHit = await cache.lookup(emb, { knobsHash: conservativeHash });
|
|
expect(cHit.hit).toBe(true);
|
|
expect(cHit.results?.length).toBe(10);
|
|
expect(cHit.results?.[0].slug).toBe('conservative/result-0');
|
|
|
|
const bHit = await cache.lookup(emb, { knobsHash: balancedHash });
|
|
expect(bHit.hit).toBe(true);
|
|
expect(bHit.results?.length).toBe(25);
|
|
|
|
const tHit = await cache.lookup(emb, { knobsHash: tokenmaxHash });
|
|
expect(tHit.hit).toBe(true);
|
|
expect(tHit.results?.length).toBe(50);
|
|
});
|
|
|
|
test('legacy rows (NULL knobs_hash) are excluded from lookup', async () => {
|
|
// Manually insert a row with NULL knobs_hash (simulating pre-v0.32.3 state).
|
|
const emb = makeEmbedding(3);
|
|
const vecStr = `[${Array.from(emb).map(v => v.toFixed(6)).join(',')}]`;
|
|
await engine.executeRaw(
|
|
`INSERT INTO query_cache (id, query_text, source_id, knobs_hash, embedding, results, meta, ttl_seconds, created_at)
|
|
VALUES ($1, $2, $3, NULL, $4::vector, $5::jsonb, $6::jsonb, 3600, now())`,
|
|
[
|
|
'legacy-row-id',
|
|
'legacy-query',
|
|
'default',
|
|
vecStr,
|
|
JSON.stringify(makeResults('legacy', 5)),
|
|
JSON.stringify({ vector_enabled: true, detail_resolved: null, expansion_applied: false }),
|
|
],
|
|
);
|
|
|
|
const cache = new SemanticQueryCache(engine);
|
|
// Any mode's lookup → MISS (NULL row is excluded by the knobs_hash filter).
|
|
expect((await cache.lookup(emb, { knobsHash: conservativeHash })).hit).toBe(false);
|
|
expect((await cache.lookup(emb, { knobsHash: balancedHash })).hit).toBe(false);
|
|
expect((await cache.lookup(emb, { knobsHash: tokenmaxHash })).hit).toBe(false);
|
|
});
|
|
|
|
test('same mode written twice updates in place (no duplicate rows)', async () => {
|
|
const cache = new SemanticQueryCache(engine);
|
|
const emb = makeEmbedding(4);
|
|
|
|
await storeCurrent(cache, 'q', emb, makeResults('first', 5), {
|
|
vector_enabled: true, detail_resolved: null, expansion_applied: false,
|
|
}, { knobsHash: balancedHash });
|
|
|
|
await storeCurrent(cache, 'q', emb, makeResults('second', 7), {
|
|
vector_enabled: true, detail_resolved: null, expansion_applied: false,
|
|
}, { knobsHash: balancedHash });
|
|
|
|
const rows = await engine.executeRaw<{ n: number }>(
|
|
`SELECT COUNT(*)::int AS n FROM query_cache WHERE query_text = 'q'`,
|
|
);
|
|
expect(rows[0].n).toBe(1);
|
|
|
|
const hit = await cache.lookup(emb, { knobsHash: balancedHash });
|
|
expect(hit.hit).toBe(true);
|
|
expect(hit.results?.length).toBe(7);
|
|
expect(hit.results?.[0].slug).toBe('second/result-0');
|
|
});
|
|
|
|
test('empty knobsHash arg writes a row but does not collide with mode-hash rows', async () => {
|
|
const cache = new SemanticQueryCache(engine);
|
|
const emb = makeEmbedding(5);
|
|
|
|
await storeCurrent(cache, 'q', emb, makeResults('no-mode', 3), {
|
|
vector_enabled: true, detail_resolved: null, expansion_applied: false,
|
|
});
|
|
|
|
// No-mode lookup hits its own row.
|
|
const noModeHit = await cache.lookup(emb);
|
|
expect(noModeHit.hit).toBe(true);
|
|
expect(noModeHit.results?.length).toBe(3);
|
|
|
|
// Conservative-hash lookup misses (the no-mode row had empty hash).
|
|
const conservativeHit = await cache.lookup(emb, { knobsHash: conservativeHash });
|
|
expect(conservativeHit.hit).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('hard-exclude cache isolation (#2825)', () => {
|
|
// Hashes computed the way hybridSearchCached does: same resolved mode, ctx
|
|
// carrying the resolved hard-exclude list. A row written by a process
|
|
// WITHOUT GBRAIN_SEARCH_EXCLUDE (defaults only) must not be served to a
|
|
// process WITH it, and vice versa.
|
|
const noEnvHash = knobsHash(resolveSearchMode({ mode: 'balanced' }), {
|
|
hardExcludes: resolveHardExcludes(undefined, undefined, undefined),
|
|
});
|
|
const envExcludeHash = knobsHash(resolveSearchMode({ mode: 'balanced' }), {
|
|
hardExcludes: resolveHardExcludes(undefined, undefined, 'private/'),
|
|
});
|
|
|
|
test('row written without excludes is NOT served to a lookup with excludes', async () => {
|
|
const cache = new SemanticQueryCache(engine);
|
|
const emb = makeEmbedding(6);
|
|
|
|
// Simulate a no-exclude process writing results that include a slug the
|
|
// excluding process must never see.
|
|
const leaky = makeResults('private', 5);
|
|
await storeCurrent(cache, 'who is alice', emb, leaky, {
|
|
vector_enabled: true, detail_resolved: null, expansion_applied: false,
|
|
}, { knobsHash: noEnvHash });
|
|
|
|
// Excluding process → MISS (falls through to a fresh, filtered query).
|
|
const excluded = await cache.lookup(emb, { knobsHash: envExcludeHash });
|
|
expect(excluded.hit).toBe(false);
|
|
|
|
// Original no-exclude process still hits its own row.
|
|
const original = await cache.lookup(emb, { knobsHash: noEnvHash });
|
|
expect(original.hit).toBe(true);
|
|
expect(original.results?.length).toBe(5);
|
|
});
|
|
|
|
test('row written WITH excludes is not served back once excludes are lifted', async () => {
|
|
const cache = new SemanticQueryCache(engine);
|
|
const emb = makeEmbedding(7);
|
|
|
|
await storeCurrent(cache, 'who is alice', emb, makeResults('filtered', 3), {
|
|
vector_enabled: true, detail_resolved: null, expansion_applied: false,
|
|
}, { knobsHash: envExcludeHash });
|
|
|
|
expect((await cache.lookup(emb, { knobsHash: noEnvHash })).hit).toBe(false);
|
|
expect((await cache.lookup(emb, { knobsHash: envExcludeHash })).hit).toBe(true);
|
|
});
|
|
});
|