mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
267 lines
12 KiB
TypeScript
267 lines
12 KiB
TypeScript
/**
|
|
* #3037 — one oversized/bad chunk must not darken its ENTIRE page, and embed
|
|
* failures must be visible on the run result.
|
|
*
|
|
* Pre-fix, all three embed paths sent a page's chunks in ONE embedBatch call
|
|
* inside a try whose catch only logged to stderr: when the batch threw,
|
|
* upsertChunks never ran, so EVERY sibling chunk stayed NULL (~8.6x blast
|
|
* radius from a single bad chunk), and EmbedResult had no failure field —
|
|
* `gbrain embed` exited 0 on a total no-op.
|
|
*
|
|
* Pinned here:
|
|
* 1. --stale and --all: a page with ONE bad chunk still embeds its other
|
|
* chunks (per-chunk isolation via embedPageTexts), failures are counted
|
|
* on result.failures, and the embedding signature is NOT stamped for a
|
|
* partially-failed page.
|
|
* 2. Cost bounding: a 429 (rate limit) does NOT fan out into N
|
|
* single-chunk calls, and neither does an AITransientError (outage) —
|
|
* isolation only fires for permanent request-shaped failures.
|
|
*
|
|
* Serial: uses mock.module (leaks across files sharing a bun process).
|
|
* The CLI exit-code half of #3037 is pinned by
|
|
* test/embed-exit-code-3037.serial.test.ts (real spawned CLI).
|
|
*/
|
|
import { describe, test, expect, mock, beforeEach, afterEach } from 'bun:test';
|
|
import type { BrainEngine } from '../src/core/engine.ts';
|
|
import { AITransientError } from '../src/core/ai/errors.ts';
|
|
|
|
// Track every embedBatch call's shape so tests can assert batch-vs-single
|
|
// fan-out behavior.
|
|
let embedCalls: string[][] = [];
|
|
let embedBatchBehavior: ((texts: string[], opts?: unknown) => Promise<Float32Array[]>) | null = null;
|
|
|
|
mock.module('../src/core/embedding.ts', () => ({
|
|
embedBatch: async (texts: string[], opts?: unknown) => {
|
|
embedCalls.push([...texts]);
|
|
if (embedBatchBehavior) return embedBatchBehavior(texts, opts);
|
|
return texts.map(() => new Float32Array(1536));
|
|
},
|
|
currentEmbeddingSignature: () => 'test:model:1536',
|
|
}));
|
|
|
|
// Import AFTER mocking.
|
|
const { runEmbedCore } = await import('../src/commands/embed.ts');
|
|
|
|
// Preflight seam (same as test/embed.serial.test.ts): make
|
|
// diagnoseEmbedding's fast-path pass without real env vars.
|
|
const { __setEmbedTransportForTests } = await import('../src/core/ai/gateway.ts');
|
|
__setEmbedTransportForTests(async () => ({ embeddings: [], usage: { tokens: 0 } } as any));
|
|
|
|
function mockEngine(overrides: Partial<Record<string, any>> = {}): BrainEngine {
|
|
const calls: { method: string; args: any[] }[] = [];
|
|
const track = (method: string) => (...args: any[]) => {
|
|
calls.push({ method, args });
|
|
if (overrides[method]) return overrides[method](...args);
|
|
return Promise.resolve(null);
|
|
};
|
|
return new Proxy({} as any, {
|
|
get(_, prop: string) {
|
|
if (prop === '_calls') return calls;
|
|
if (overrides[prop]) return overrides[prop];
|
|
return track(prop);
|
|
},
|
|
});
|
|
}
|
|
|
|
/** Permanent 400-shaped batch failure (e.g. one oversized chunk). */
|
|
function permanentBatchError(): Error {
|
|
const err = new Error('batch contains an invalid input');
|
|
(err as any).cause = { status: 400 };
|
|
return err;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
embedCalls = [];
|
|
embedBatchBehavior = null;
|
|
process.env.GBRAIN_EMBED_CONCURRENCY = '1';
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete process.env.GBRAIN_EMBED_CONCURRENCY;
|
|
});
|
|
|
|
// Behavior: the whole-page batch 400s; retried per-chunk, only 'BAD' fails.
|
|
function oneBadChunkBehavior() {
|
|
embedBatchBehavior = async (texts: string[]) => {
|
|
if (texts.length > 1) throw permanentBatchError();
|
|
if (texts[0] === 'BAD') throw permanentBatchError();
|
|
return texts.map(() => new Float32Array(1536));
|
|
};
|
|
}
|
|
|
|
const THREE_CHUNKS = [
|
|
{ chunk_index: 0, chunk_text: 'good-a', chunk_source: 'compiled_truth' as const, embedded_at: null, token_count: 1 },
|
|
{ chunk_index: 1, chunk_text: 'BAD', chunk_source: 'compiled_truth' as const, embedded_at: null, token_count: 1 },
|
|
{ chunk_index: 2, chunk_text: 'good-b', chunk_source: 'compiled_truth' as const, embedded_at: null, token_count: 1 },
|
|
];
|
|
|
|
describe('#3037 — one bad chunk no longer darkens its page', () => {
|
|
test('--stale: siblings of one bad chunk get embedded; failure counted; signature not stamped', async () => {
|
|
oneBadChunkBehavior();
|
|
const stale = THREE_CHUNKS.map(c => ({
|
|
slug: 'poisoned-page', chunk_index: c.chunk_index, chunk_text: c.chunk_text,
|
|
chunk_source: c.chunk_source, model: null, token_count: 1, source_id: 'default', page_id: 1,
|
|
}));
|
|
const upsertCalls: Array<{ slug: string; chunks: any[] }> = [];
|
|
const engine = mockEngine({
|
|
countStaleChunks: async () => 3,
|
|
listStaleChunks: async () => stale,
|
|
getChunks: async () => THREE_CHUNKS,
|
|
upsertChunks: async (slug: string, chunks: any[]) => { upsertCalls.push({ slug, chunks }); },
|
|
});
|
|
|
|
const result = await runEmbedCore(engine, { stale: true });
|
|
|
|
// Pre-fix: the batch threw, upsertChunks never ran, embedded stayed 0.
|
|
expect(upsertCalls).toHaveLength(1);
|
|
const byIdx = new Map(upsertCalls[0].chunks.map((c: any) => [c.chunk_index, c]));
|
|
expect(byIdx.get(0)!.embedding).toBeInstanceOf(Float32Array);
|
|
expect(byIdx.get(2)!.embedding).toBeInstanceOf(Float32Array);
|
|
expect(byIdx.get(1)!.embedding).toBeUndefined(); // bad chunk stays NULL (re-run picks it up)
|
|
expect(result.embedded).toBe(2);
|
|
expect(result.failures).toBe(1);
|
|
expect(result.failure_samples).toHaveLength(1);
|
|
expect(result.failure_samples[0]).toContain('poisoned-page');
|
|
// Partially-failed page must NOT be stamped as current provenance.
|
|
const stamps = (engine as any)._calls.filter((c: any) => c.method === 'setPageEmbeddingSignature');
|
|
expect(stamps).toHaveLength(0);
|
|
});
|
|
|
|
test('--all: same isolation on the listPages path', async () => {
|
|
oneBadChunkBehavior();
|
|
const upsertCalls: Array<{ slug: string; chunks: any[] }> = [];
|
|
const engine = mockEngine({
|
|
listPages: async () => [{ slug: 'poisoned-page', source_id: 'default' }],
|
|
getChunks: async () => THREE_CHUNKS,
|
|
upsertChunks: async (slug: string, chunks: any[]) => { upsertCalls.push({ slug, chunks }); },
|
|
});
|
|
|
|
const result = await runEmbedCore(engine, { all: true });
|
|
|
|
expect(upsertCalls).toHaveLength(1);
|
|
const byIdx = new Map(upsertCalls[0].chunks.map((c: any) => [c.chunk_index, c]));
|
|
expect(byIdx.get(0)!.embedding).toBeInstanceOf(Float32Array);
|
|
expect(byIdx.get(1)!.embedding).toBeUndefined();
|
|
expect(result.embedded).toBe(2);
|
|
expect(result.failures).toBe(1);
|
|
const stamps = (engine as any)._calls.filter((c: any) => c.method === 'setPageEmbeddingSignature');
|
|
expect(stamps).toHaveLength(0);
|
|
});
|
|
|
|
test('--stale x #3507: fan-out retries the WRAPPED texts and a partially-failed page is not restamped', async () => {
|
|
// Composition pin for the #3037 + #3538 merge: the per-chunk isolation
|
|
// retry must re-send the contextually WRAPPED text (raw chunk_text here
|
|
// would silently strip prefixes on exactly the pages that hit an error),
|
|
// and restampIfDemotedToTitleTier must NOT fire when isolation left
|
|
// chunks NULL (the page was not fully re-embedded — restamping would
|
|
// make contextual_retrieval_mode lie again, the exact #3461 bug).
|
|
embedBatchBehavior = async (texts: string[]) => {
|
|
if (texts.length > 1) throw permanentBatchError();
|
|
if (texts[0].includes('BAD')) throw permanentBatchError();
|
|
return texts.map(() => new Float32Array(1536));
|
|
};
|
|
const stale = THREE_CHUNKS.map(c => ({
|
|
slug: 'wrapped-page', chunk_index: c.chunk_index, chunk_text: c.chunk_text,
|
|
chunk_source: c.chunk_source, model: null, token_count: 1, source_id: 'default', page_id: 1,
|
|
}));
|
|
const engine = mockEngine({
|
|
countStaleChunks: async () => 3,
|
|
listStaleChunks: async () => stale,
|
|
getPage: async () => ({
|
|
slug: 'wrapped-page', title: 'My Title', compiled_truth: 'x', timeline: '',
|
|
source_id: 'default', contextual_retrieval_mode: 'per_chunk_synopsis',
|
|
}),
|
|
getChunks: async () => THREE_CHUNKS,
|
|
upsertChunks: async () => {},
|
|
});
|
|
|
|
const result = await runEmbedCore(engine, { stale: true });
|
|
|
|
// Every embed call — the failed batch AND each single-chunk retry —
|
|
// carries the stored-mode contextual prefix (fenced_code exemption is
|
|
// pinned upstream in test/embedding-context.test.ts).
|
|
expect(embedCalls.length).toBeGreaterThan(1);
|
|
for (const call of embedCalls) {
|
|
for (const text of call) expect(text).toStartWith('<context>My Title\n</context>\n');
|
|
}
|
|
expect(result.embedded).toBe(2);
|
|
expect(result.failures).toBe(1);
|
|
// Partially-failed page: neither signature-stamped nor CR-restamped.
|
|
const calls = (engine as any)._calls as Array<{ method: string }>;
|
|
expect(calls.filter(c => c.method === 'setPageEmbeddingSignature')).toHaveLength(0);
|
|
expect(calls.filter(c => c.method === 'updatePageContextualRetrievalState')).toHaveLength(0);
|
|
});
|
|
|
|
test('--stale: a fully-failed page is counted on result.failures (no more silent no-op)', async () => {
|
|
embedBatchBehavior = async () => { throw permanentBatchError(); };
|
|
const stale = THREE_CHUNKS.map(c => ({
|
|
slug: 'dark-page', chunk_index: c.chunk_index, chunk_text: c.chunk_text,
|
|
chunk_source: c.chunk_source, model: null, token_count: 1, source_id: 'default', page_id: 1,
|
|
}));
|
|
const engine = mockEngine({
|
|
countStaleChunks: async () => 3,
|
|
listStaleChunks: async () => stale,
|
|
getChunks: async () => THREE_CHUNKS,
|
|
upsertChunks: async () => {},
|
|
});
|
|
|
|
const result = await runEmbedCore(engine, { stale: true });
|
|
|
|
expect(result.embedded).toBe(0);
|
|
expect(result.failures).toBe(3);
|
|
expect(result.failure_samples[0]).toContain('dark-page');
|
|
});
|
|
});
|
|
|
|
describe('#3037 — cost bounding: no per-chunk fan-out on transient failures', () => {
|
|
test('sustained 429 does not fan out into single-chunk calls', async () => {
|
|
embedBatchBehavior = async () => {
|
|
const err = new Error('Rate limit reached. Please try again in 0ms.');
|
|
(err as any).cause = { status: 429 };
|
|
throw err;
|
|
};
|
|
const stale = THREE_CHUNKS.map(c => ({
|
|
slug: 'rate-limited-page', chunk_index: c.chunk_index, chunk_text: c.chunk_text,
|
|
chunk_source: c.chunk_source, model: null, token_count: 1, source_id: 'default', page_id: 1,
|
|
}));
|
|
const engine = mockEngine({
|
|
countStaleChunks: async () => 3,
|
|
listStaleChunks: async () => stale,
|
|
getChunks: async () => THREE_CHUNKS,
|
|
upsertChunks: async () => {},
|
|
});
|
|
|
|
const result = await runEmbedCore(engine, { stale: true });
|
|
|
|
// Every call must be the full 3-text batch: embedBatchWithBackoff's own
|
|
// retries (initial + MAX_RATE_LIMIT_RETRIES), never a 1-text isolation call
|
|
// hammering the limiter.
|
|
expect(embedCalls.length).toBeGreaterThan(1);
|
|
for (const call of embedCalls) expect(call).toHaveLength(3);
|
|
expect(result.embedded).toBe(0);
|
|
expect(result.failures).toBe(3);
|
|
}, 30_000);
|
|
|
|
test('AITransientError (outage/network) does not fan out', async () => {
|
|
embedBatchBehavior = async () => { throw new AITransientError('upstream 502', { status: 502 }); };
|
|
const stale = THREE_CHUNKS.map(c => ({
|
|
slug: 'outage-page', chunk_index: c.chunk_index, chunk_text: c.chunk_text,
|
|
chunk_source: c.chunk_source, model: null, token_count: 1, source_id: 'default', page_id: 1,
|
|
}));
|
|
const engine = mockEngine({
|
|
countStaleChunks: async () => 3,
|
|
listStaleChunks: async () => stale,
|
|
getChunks: async () => THREE_CHUNKS,
|
|
upsertChunks: async () => {},
|
|
});
|
|
|
|
const result = await runEmbedCore(engine, { stale: true });
|
|
|
|
// Non-429 → no backoff retries; transient → no isolation. Exactly 1 call.
|
|
expect(embedCalls).toHaveLength(1);
|
|
expect(embedCalls[0]).toHaveLength(3);
|
|
expect(result.embedded).toBe(0);
|
|
expect(result.failures).toBe(3);
|
|
});
|
|
});
|