mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* fix(cost): embedding cost preview uses configured model rate, not hardcoded OpenAI
The sync --all cost gate computed spend from a hardcoded
EMBEDDING_COST_PER_1K_TOKENS = 0.00013 (OpenAI text-embedding-3-large)
and labeled the preview with the back-compat EMBEDDING_MODEL constant,
regardless of the actually-configured embedding model. A brain running a
cheaper model (e.g. zeroentropyai:zembed-1 @ $0.05/Mtok) saw a preview
that named the wrong provider and over-stated spend ~2.6x ($337 vs $130
on a 2.6B-token corpus).
estimateEmbeddingCostUsd now resolves the live model via the gateway and
prices it through embedding-pricing.ts (the existing per-provider:model
table), falling back to the OpenAI rate only when the gateway is
unconfigured (unit-test context) or the model is unknown. sync.ts surfaces
the real model name in the preview message and JSON.
Regression test pins model-aware pricing: openai 3-large vs zembed-1 must
produce materially different previews; collapsing both to the OpenAI number
fails the assertion.
* fix(cost): sync --all gate is informational when embed is deferred; delta-aware inline gate
Under federated_v2 (default), sync --all DEFERS embedding to per-source
embed-backfill jobs that already cap spend at $25/source/24h. The v0.20
cost gate predated that cap and fired ConfirmationRequired + exit 2 on
EVERY non-TTY sync --all without --yes, regardless of cost — blocking
nightly crons over already-synced corpora and forcing permanent --yes.
The gate is now mode-aware:
- Deferred embed (v2 default): print an FYI deferred notice (cap-aware,
"not charged by this sync") + the stale-chunk backlog estimate, and
NEVER exit 2. The backfill cap is the real money gate.
- Inline embed (v2 off, or --serial without --no-embed): keep the
blocking gate, but estimate the actual delta — full-tree ceiling for
changed sources (unchanged sources contribute 0 via the same git +
chunker_version "do work?" gate doctor/sync use) + stale backlog — and
block only when it exceeds the new configurable floor
sync.cost_gate_min_usd (default $0.50).
New pure helpers in embedding.ts (willEmbedSynchronously, shouldBlockSync)
keep the decision logic hermetically testable. New engine method
sumStaleChunkChars (both engines) prices the embedding backlog via
estimateCostFromChars. estimateSyncAllCost's per-source walk extracted to
estimateSourceTreeTokens (reused by the inline estimator).
Regressions pinned: R-1 deferred non-TTY never exit 2 (headline), R-2
inline above-floor still exit 2 (protection), plus the willEmbedSynchronously
/ shouldBlockSync matrix and sumStaleChunkChars engine + scope + embed_skip
coverage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(embed): real stale semantics — re-embed on model/dims swap (migration v108)
Pre-v0.41.30 "stale" meant only `embedding IS NULL`, so swapping the
embedding model or dimensions left the whole corpus silently embedded under
the OLD model — `embed --stale` ignored it and search quality quietly
degraded.
New `pages.embedding_signature` (TEXT, migration v108) stamps the embedding
provenance (`<provider:model>:<dims>`) whenever a page's chunks are embedded.
A later model/dims swap makes the stored signature differ from the current
one, which the embed paths now detect and re-embed.
GRANDFATHER (critical): the stale predicate is
`embedding IS NULL OR (embedding_signature IS NOT NULL AND <> $current)`
so a NULL signature is NEVER stale. After the migration every existing page
has NULL → none flagged → the next `embed --stale` does NOT re-embed the
whole corpus. Signatures are stamped going forward only.
Surface:
- countStaleChunks / sumStaleChunkChars gain an optional `signature` opt
that widens staleness (read-only; used by the dry-run preview + the
sync cost preview, which is now signature-aware).
- invalidateStaleSignatureEmbeddings(signature, sourceId?) NULLs the
embeddings of signature-mismatched pages so the EXISTING NULL-embedding
cursor (listStaleChunks, untouched) re-embeds them — keeps the keyset
pagination logic intact.
- setPageEmbeddingSignature stamps after a page's chunks land.
- Both embed loops wired: `gbrain embed --stale`/`--all` (embed.ts) and the
embed-backfill minion (embed-stale.ts) invalidate-then-stamp.
Migration v108 + bootstrap probe (both engines) + REQUIRED_BOOTSTRAP_COVERAGE
entry. Pinned by test/embedding-signature-stale.test.ts (R-4 grandfather,
mismatch detection, matching no-op, scoped invalidate, stamp) + the
bootstrap-coverage gate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(sync): surface embed-backfill job state in sources status + deferred notice
Under federated_v2, `sync --all` exits 0 and embedding lags behind in
embed-backfill jobs (subject to cooldown + the per-source 24h cap). Pre-fix
an operator had no signal those jobs were queued or lagging — the sync looked
"done" while embeddings trickled in later.
`gbrain sources status` now shows a BACKFILL column per source
(active(N)/queued(N)/idle) plus the last completion timestamp, read from
minion_jobs. The deferred-sync notice appends "N backfill job(s) queued" so a
cron operator sees work is enqueued, not lost. Both reads are best-effort —
a brain that never ran a worker (no minion_jobs table) reports idle/0 instead
of crashing the dashboard.
SyncStatusReportSource gains backfill_queued / backfill_active /
backfill_last_completed_at (additive; JSON envelope schema_version unchanged).
Pinned by a new case in test/e2e/sync-status-pglite.test.ts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test: add currentEmbeddingSignature to embedding.ts mocks + sync stale EXPECTED_PHASES
Commit 3 made embed.ts import currentEmbeddingSignature from embedding.ts.
Four tests mock.module the whole embedding.ts and omitted the new export, so
embed.ts (imported transitively) failed at load with "Export named
'currentEmbeddingSignature' not found". Add the export to each mock:
embed.serial.test.ts, e2e/cycle.test.ts, e2e/dream.test.ts,
e2e/dream-cycle-phase-order-pglite.test.ts.
Also sync the stale EXPECTED_PHASES in dream-cycle-phase-order-pglite.test.ts
to match cycle.ts ALL_PHASES — extract_atoms, synthesize_concepts, and
conversation_facts_backfill drifted in after the test was last touched
(v0.41.0.0) and were never added, so both phase-order assertions were failing
on the branch before this wave (confirmed against 0906ab0a). The dry-run cycle
emits all 20 phases, so mirroring the constant makes both assertions pass.
Pre-existing, unrelated: cycle.test.ts / dream.test.ts have 5 runCycle
failures via direct `bun test` (the conversation_facts_backfill phase uses the
module-singleton getConnection) — present identically at 0906ab0a, not touched
by this wave.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test: pin R-3 chunker-drift regression + embed-signature stamp-call wiring
Ship-workflow coverage audit flagged two gaps:
- R-3 (mandatory regression) had no dedicated test: the inline unchanged-source
short-circuit requires git-unchanged AND chunker_version match, but nothing
pinned the chunker half. Add a case where git is unchanged (HEAD==last_commit,
clean) but chunker_version is stale → estimate still fires (exit 2), plus a
control where chunker matches → short-circuits to $0 (no block).
- The embed loops' setPageEmbeddingSignature call-site was only kept green by the
mock, never asserted. Add a test that runs `embed --all` and asserts the stamp
fires once per page with the current signature.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(embed): stamp embedding_signature on the inline import + per-slug paths (F1); inline cost gate counts new-content only (F2)
Adversarial review caught that the stale-detection feature was inert for
non-federated/inline brains: the embed-write paths that DON'T go through
embed.ts/embed-stale.ts never stamped pages.embedding_signature.
F1 — stamp at the remaining write sites:
- embedPage (gbrain embed <slug> + sync's post-import runEmbedCore({slugs}))
- importFromContent markdown branch (inline import/sync embed + gbrain import)
- importCodeFile (only when EVERY chunk was freshly embedded this call —
reuse-by-hash carries old-model vectors, so a mixed page stays unstamped
rather than falsely marked current)
Without this, inline-synced pages kept NULL signatures → grandfathered → never
re-embedded on a model/dims swap. Now all embed-write paths stamp.
F2 — coupled regression the F1 fix would otherwise introduce: the inline cost
gate added the stale backlog (NULL + signature drift) into the BLOCKING cost,
but `gbrain sync` inline only embeds new/changed content — the backlog is
`gbrain embed --stale`'s job. Once F1 gives inline brains real signatures, a
model swap would inflate the inline gate and block the next cron for cost the
sync never incurs. Inline blocking cost is now new-content only; the stale
backlog is shown informationally ("pending gbrain embed --stale"). Deferred
path keeps the signature-aware backlog FYI (the backfill does clear it).
Pinned by test/import-signature-stamp.serial.test.ts (inline stamp + --no-embed
NULL) and the existing R-2/R-3 inline-gate tests (still exit 2 on new-content).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore: bump version and changelog (v0.41.30.0)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(sources): wire BACKFILL into the real `sources status` path (P0a); guard partial-page signature stamping (P0b)
Codex adversarial review caught two issues in the v0.41.30 wave:
P0a — `gbrain sources status` routes through computeAllSourceMetrics
(source-health.ts), not the buildSyncStatusReport helper where the BACKFILL
column was added, so the CLI never showed it. Add per-source embed-backfill
active/queued counts to computeAllSourceMetrics (one extra FILTER on the
existing minion_jobs query) and render a BACKFILL column in `sources status`.
The deferred-sync notice's queued-job count (live sync path) already worked.
P0b — embedPage / embedAllStale / embed-stale stamped embedding_signature
unconditionally after embedding only the STALE subset of a page's chunks. A
partially-embedded page (some chunks preserved from a prior embed under
unknown/old provenance) would be falsely marked current, hiding the old
vectors from future stale detection. Now stamp only when EVERY chunk of the
page was (re)embedded this pass (toEmbed === chunks / stale === existing).
importFromContent embeds the full chunk set so it stays unconditional;
importCodeFile already had the equivalent guard. `gbrain embed --all` fully
re-embeds and stamps mixed pages.
Accepted as documented limitations (not fixed): the inline cost gate can
over-estimate a >100-file `--serial` sync that performSync will defer
(non-default mode, conservative-high bias), and model-swap invalidation NULLs
drifted vectors before re-embed (a deliberate, rare operation).
Pinned by a new backfill-counts case in test/source-health.test.ts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: update project documentation for v0.41.30.0
Refresh CLAUDE.md Key Files + Commands for the embedding cost-model + stale-semantics wave: model-aware cost helpers in embedding.ts (currentEmbeddingPricePerMTok / currentEmbeddingSignature / willEmbedSynchronously / shouldBlockSync), the embedding-signature stale-detection engine quartet (sumStaleChunkChars / setPageEmbeddingSignature / invalidateStaleSignatureEmbeddings + widened countStaleChunks), migration v108, signature stamping across embed.ts / import-file.ts, the mode-aware sync --all cost gate + sync.cost_gate_min_usd config key, and the sources status BACKFILL column. Add a same-dimension-swap auto-reembed note to docs/embedding-migrations.md. Regenerate llms-full.txt.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore: re-version 0.41.30.0 → 0.41.31.0 (queue slot)
Mechanical version-string sweep across VERSION, package.json, CHANGELOG,
CLAUDE.md, docs, source/test comments, and regenerated llms bundles. No logic
change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(test): pin embedding dim in cosine-rescore-column test (CI shard-5 contamination)
CI shard 5 failed deterministically with `expected 1280 dimensions, not 1536`.
Root cause: cosine-rescore-column.test.ts hardcodes 1536-dim `embedding`
vectors and asserts length 1536, but its beforeAll ran `initSchema()` with no
gateway config. initSchema sizes the `embedding` column from
getEmbeddingDimensions(), whose default is 1280 (zeroentropyai:zembed-1). The
test only passed by inheriting a leaked 1536 gateway config from an earlier
test (or, locally, from ~/.gbrain). When the v0.41.31 merge shifted the
weight-aware shard bin-packing, the file order changed so the 1280 default won
in CI → vector(1280) column → 1536 insert rejected. (Passed locally because
the dev machine's ~/.gbrain resolves 1536.)
Fix: configureGateway({ openai:text-embedding-3-large, 1536 }) in beforeAll
BEFORE connect/initSchema so the column is deterministically vector(1536)
regardless of ambient/leaked state, and resetGateway() in afterAll for
hygiene. Proven: under a forced-1280 gateway preload the old test reproduces
the exact CI error and the fixed test passes (4/4).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: t <t@t>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
762 lines
34 KiB
TypeScript
762 lines
34 KiB
TypeScript
import { describe, test, expect, mock, beforeEach, afterEach } from 'bun:test';
|
||
import type { BrainEngine } from '../src/core/engine.ts';
|
||
|
||
// Mock the embedding module BEFORE importing runEmbed, so runEmbed picks up
|
||
// the mocked embedBatch. We track max concurrent invocations via a counter
|
||
// that increments on entry and decrements when the mock resolves.
|
||
let activeEmbedCalls = 0;
|
||
let maxConcurrentEmbedCalls = 0;
|
||
let totalEmbedCalls = 0;
|
||
// D5: capture per-call opts so tests can assert maxRetries / abortSignal
|
||
// passthrough into the gateway path.
|
||
let lastEmbedBatchOpts: unknown = undefined;
|
||
// D5: pluggable behavior for tests that need to simulate 429s or aborts.
|
||
let embedBatchBehavior: ((texts: string[], opts?: unknown) => Promise<Float32Array[]>) | null = null;
|
||
|
||
mock.module('../src/core/embedding.ts', () => ({
|
||
embedBatch: async (texts: string[], opts?: unknown) => {
|
||
activeEmbedCalls++;
|
||
totalEmbedCalls++;
|
||
lastEmbedBatchOpts = opts;
|
||
if (activeEmbedCalls > maxConcurrentEmbedCalls) {
|
||
maxConcurrentEmbedCalls = activeEmbedCalls;
|
||
}
|
||
try {
|
||
if (embedBatchBehavior) {
|
||
return await embedBatchBehavior(texts, opts);
|
||
}
|
||
// Default: simulate API latency so concurrent workers actually overlap.
|
||
await new Promise(r => setTimeout(r, 30));
|
||
return texts.map(() => new Float32Array(1536));
|
||
} finally {
|
||
activeEmbedCalls--;
|
||
}
|
||
},
|
||
// v0.41.31: embedAll/embedAllStale read the current embedding signature to
|
||
// stamp provenance. The mock returns a stable value; the mock engine's
|
||
// setPageEmbeddingSignature / invalidateStaleSignatureEmbeddings resolve to
|
||
// null via the Proxy default, so the signature value is inert here.
|
||
currentEmbeddingSignature: () => 'test:model:1536',
|
||
}));
|
||
|
||
// Import AFTER mocking.
|
||
const { runEmbed } = await import('../src/commands/embed.ts');
|
||
|
||
// v0.41.6.0 D1: runEmbedCore now preflights embedding credentials. This
|
||
// test stack uses the LEGACY embedBatch mock path, not the gateway,
|
||
// so the preflight would throw before our mocks see anything. Install
|
||
// the gateway embed transport seam so diagnoseEmbedding's fast-path
|
||
// flags the preflight as ok without touching real env vars.
|
||
const { __setEmbedTransportForTests } = await import('../src/core/ai/gateway.ts');
|
||
__setEmbedTransportForTests(async () => ({ embeddings: [], usage: { tokens: 0 } } as any));
|
||
|
||
// Proxy-based mock engine that matches test/import-file.test.ts pattern.
|
||
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);
|
||
};
|
||
const engine = new Proxy({} as any, {
|
||
get(_, prop: string) {
|
||
if (prop === '_calls') return calls;
|
||
if (overrides[prop]) return overrides[prop];
|
||
return track(prop);
|
||
},
|
||
});
|
||
return engine;
|
||
}
|
||
|
||
beforeEach(() => {
|
||
activeEmbedCalls = 0;
|
||
maxConcurrentEmbedCalls = 0;
|
||
totalEmbedCalls = 0;
|
||
lastEmbedBatchOpts = undefined;
|
||
embedBatchBehavior = null;
|
||
});
|
||
|
||
afterEach(() => {
|
||
delete process.env.GBRAIN_EMBED_CONCURRENCY;
|
||
delete process.env.GBRAIN_EMBED_TIME_BUDGET_MS;
|
||
});
|
||
|
||
describe('runEmbed --all (parallel)', () => {
|
||
test('runs embedBatch calls concurrently across pages', async () => {
|
||
const NUM_PAGES = 20;
|
||
const pages = Array.from({ length: NUM_PAGES }, (_, i) => ({ slug: `page-${i}` }));
|
||
// Each page has one chunk without an embedding (stale).
|
||
const chunksBySlug = new Map(
|
||
pages.map(p => [
|
||
p.slug,
|
||
[{ chunk_index: 0, chunk_text: `text for ${p.slug}`, chunk_source: 'compiled_truth', embedded_at: null, token_count: 4 }],
|
||
]),
|
||
);
|
||
|
||
const engine = mockEngine({
|
||
listPages: async () => pages,
|
||
getChunks: async (slug: string) => chunksBySlug.get(slug) || [],
|
||
upsertChunks: async () => {},
|
||
});
|
||
|
||
process.env.GBRAIN_EMBED_CONCURRENCY = '10';
|
||
|
||
await runEmbed(engine, ['--all']);
|
||
|
||
expect(totalEmbedCalls).toBe(NUM_PAGES);
|
||
// Concurrency actually happened.
|
||
expect(maxConcurrentEmbedCalls).toBeGreaterThan(1);
|
||
// And stayed within the configured limit.
|
||
expect(maxConcurrentEmbedCalls).toBeLessThanOrEqual(10);
|
||
});
|
||
|
||
test('v0.41.31: stamps embedding_signature after embedding each page (--all)', async () => {
|
||
const pages = [{ slug: 'a', source_id: 'default' }, { slug: 'b', source_id: 'default' }];
|
||
const chunksBySlug = new Map(
|
||
pages.map(p => [
|
||
p.slug,
|
||
[{ chunk_index: 0, chunk_text: `text ${p.slug}`, chunk_source: 'compiled_truth', embedded_at: null, token_count: 4 }],
|
||
]),
|
||
);
|
||
const engine = mockEngine({
|
||
listPages: async () => pages,
|
||
getChunks: async (slug: string) => chunksBySlug.get(slug) || [],
|
||
upsertChunks: async () => {},
|
||
});
|
||
|
||
await runEmbed(engine, ['--all']);
|
||
|
||
// The wiring gap this pins: embedAll must CALL setPageEmbeddingSignature
|
||
// after upsertChunks, with the current signature (mocked to test:model:1536).
|
||
const stampCalls = (engine as any)._calls.filter((c: any) => c.method === 'setPageEmbeddingSignature');
|
||
expect(stampCalls.length).toBe(2); // one per page
|
||
expect(stampCalls[0].args[1]).toEqual({ sourceId: 'default', signature: 'test:model:1536' });
|
||
});
|
||
|
||
test('respects GBRAIN_EMBED_CONCURRENCY=1 (serial)', async () => {
|
||
const pages = Array.from({ length: 5 }, (_, i) => ({ slug: `page-${i}` }));
|
||
const chunksBySlug = new Map(
|
||
pages.map(p => [
|
||
p.slug,
|
||
[{ chunk_index: 0, chunk_text: `text ${p.slug}`, chunk_source: 'compiled_truth', embedded_at: null, token_count: 4 }],
|
||
]),
|
||
);
|
||
|
||
const engine = mockEngine({
|
||
listPages: async () => pages,
|
||
getChunks: async (slug: string) => chunksBySlug.get(slug) || [],
|
||
upsertChunks: async () => {},
|
||
});
|
||
|
||
process.env.GBRAIN_EMBED_CONCURRENCY = '1';
|
||
|
||
await runEmbed(engine, ['--all']);
|
||
|
||
expect(totalEmbedCalls).toBe(5);
|
||
expect(maxConcurrentEmbedCalls).toBe(1);
|
||
});
|
||
|
||
test('skips pages whose chunks are all already embedded when --stale', async () => {
|
||
const chunksBySlug = new Map<string, any[]>([
|
||
['fresh', [{ chunk_index: 0, chunk_text: 'hi', chunk_source: 'compiled_truth', embedded_at: '2026-01-01', token_count: 1 }]],
|
||
['stale', [{ chunk_index: 0, chunk_text: 'hi', chunk_source: 'compiled_truth', embedded_at: null, token_count: 1 }]],
|
||
]);
|
||
// Stale path uses countStaleChunks + listStaleChunks (SQL-side filter), not listPages.
|
||
// D5a: source_id + page_id required on StaleChunkRow as of v0.33.3 cursor pagination.
|
||
const stale = [
|
||
{ slug: 'stale', chunk_index: 0, chunk_text: 'hi', chunk_source: 'compiled_truth' as const, model: null, token_count: 1, source_id: 'default', page_id: 1 },
|
||
];
|
||
|
||
const engine = mockEngine({
|
||
countStaleChunks: async () => 1,
|
||
listStaleChunks: async () => stale,
|
||
getChunks: async (slug: string) => chunksBySlug.get(slug) || [],
|
||
upsertChunks: async () => {},
|
||
});
|
||
|
||
process.env.GBRAIN_EMBED_CONCURRENCY = '5';
|
||
|
||
await runEmbed(engine, ['--stale']);
|
||
|
||
// Only the stale page triggers an embedBatch call.
|
||
expect(totalEmbedCalls).toBe(1);
|
||
});
|
||
});
|
||
|
||
// ────────────────────────────────────────────────────────────────
|
||
// runEmbedCore dry-run mode (v0.17 regression guard)
|
||
// ────────────────────────────────────────────────────────────────
|
||
|
||
describe('runEmbedCore --dry-run never calls the embedding model', () => {
|
||
test('dry-run --all with stale chunks: no embedBatch calls, accurate would_embed', async () => {
|
||
const { runEmbedCore } = await import('../src/commands/embed.ts');
|
||
const pages = Array.from({ length: 3 }, (_, i) => ({ slug: `page-${i}` }));
|
||
// All 3 pages have 2 stale chunks each (none embedded).
|
||
const chunksBySlug = new Map<string, any[]>(
|
||
pages.map(p => [
|
||
p.slug,
|
||
[
|
||
{ chunk_index: 0, chunk_text: 'a', chunk_source: 'compiled_truth', embedded_at: null, token_count: 1 },
|
||
{ chunk_index: 1, chunk_text: 'b', chunk_source: 'compiled_truth', embedded_at: null, token_count: 1 },
|
||
],
|
||
]),
|
||
);
|
||
// SQL-side stale path: 6 stale rows across 3 pages.
|
||
// D5a: source_id + page_id required on StaleChunkRow.
|
||
const stale = pages.flatMap((p, pi) => [
|
||
{ slug: p.slug, chunk_index: 0, chunk_text: 'a', chunk_source: 'compiled_truth' as const, model: null, token_count: 1, source_id: 'default', page_id: pi + 1 },
|
||
{ slug: p.slug, chunk_index: 1, chunk_text: 'b', chunk_source: 'compiled_truth' as const, model: null, token_count: 1, source_id: 'default', page_id: pi + 1 },
|
||
]);
|
||
|
||
const upserts: string[] = [];
|
||
const engine = mockEngine({
|
||
countStaleChunks: async () => 6,
|
||
listStaleChunks: async () => stale,
|
||
listPages: async () => pages,
|
||
getChunks: async (slug: string) => chunksBySlug.get(slug) || [],
|
||
upsertChunks: async (slug: string) => { upserts.push(slug); },
|
||
});
|
||
|
||
const result = await runEmbedCore(engine, { stale: true, dryRun: true });
|
||
|
||
// No OpenAI calls.
|
||
expect(totalEmbedCalls).toBe(0);
|
||
// No DB writes.
|
||
expect(upserts).toEqual([]);
|
||
// Accurate counts.
|
||
expect(result.dryRun).toBe(true);
|
||
expect(result.embedded).toBe(0);
|
||
expect(result.would_embed).toBe(6); // 3 pages * 2 chunks each
|
||
// skipped is 0 in the new SQL-side path: we never considered non-stale chunks.
|
||
expect(result.skipped).toBe(0);
|
||
expect(result.total_chunks).toBe(6); // only stale chunks counted in SQL-side path
|
||
// v0.33.3 cherry-pick: dry-run skips the cursor walk and only does a
|
||
// countStaleChunks call. pages_processed is 0 because we don't enumerate
|
||
// pages in dry-run (cheaper pre-flight).
|
||
expect(result.pages_processed).toBe(0);
|
||
});
|
||
|
||
test('dry-run --stale correctly identifies stale chunks (SQL-side path)', async () => {
|
||
const { runEmbedCore } = await import('../src/commands/embed.ts');
|
||
// SQL-side stale: only the 3 chunks where embedding IS NULL come back,
|
||
// grouped by slug. 'fresh' page has no stale rows so it's not in the result.
|
||
// D5a: source_id + page_id required on StaleChunkRow.
|
||
const stale = [
|
||
{ slug: 'partial', chunk_index: 1, chunk_text: 'b', chunk_source: 'compiled_truth' as const, model: null, token_count: 1, source_id: 'default', page_id: 1 },
|
||
{ slug: 'all-stale', chunk_index: 0, chunk_text: 'a', chunk_source: 'compiled_truth' as const, model: null, token_count: 1, source_id: 'default', page_id: 2 },
|
||
{ slug: 'all-stale', chunk_index: 1, chunk_text: 'b', chunk_source: 'compiled_truth' as const, model: null, token_count: 1, source_id: 'default', page_id: 2 },
|
||
];
|
||
|
||
const engine = mockEngine({
|
||
countStaleChunks: async () => 3,
|
||
listStaleChunks: async () => stale,
|
||
upsertChunks: async () => {},
|
||
});
|
||
|
||
const result = await runEmbedCore(engine, { stale: true, dryRun: true });
|
||
|
||
expect(totalEmbedCalls).toBe(0);
|
||
expect(result.dryRun).toBe(true);
|
||
expect(result.would_embed).toBe(3); // 1 from 'partial' + 2 from 'all-stale'
|
||
// SQL-side path does not see non-stale chunks, so skipped=0 and total_chunks=stale-count.
|
||
// Callers wanting full coverage should call engine.getStats()/getHealth() afterward.
|
||
expect(result.skipped).toBe(0);
|
||
expect(result.total_chunks).toBe(3);
|
||
// v0.33.3 cherry-pick: pages_processed=0 in dry-run because we skip
|
||
// the cursor walk (countStaleChunks-only pre-flight).
|
||
expect(result.pages_processed).toBe(0);
|
||
});
|
||
|
||
test('dry-run --slugs on a single page counts stale chunks, no API calls', async () => {
|
||
const { runEmbedCore } = await import('../src/commands/embed.ts');
|
||
const chunks = [
|
||
{ chunk_index: 0, chunk_text: 'a', chunk_source: 'compiled_truth', embedded_at: null, token_count: 1 },
|
||
{ chunk_index: 1, chunk_text: 'b', chunk_source: 'compiled_truth', embedded_at: null, token_count: 1 },
|
||
{ chunk_index: 2, chunk_text: 'c', chunk_source: 'compiled_truth', embedded_at: '2026-01-01', token_count: 1 },
|
||
];
|
||
|
||
const engine = mockEngine({
|
||
getPage: async () => ({ slug: 'my-page', compiled_truth: 'text', timeline: '' }),
|
||
getChunks: async () => chunks,
|
||
upsertChunks: async () => {},
|
||
});
|
||
|
||
const result = await runEmbedCore(engine, { slugs: ['my-page'], dryRun: true });
|
||
|
||
expect(totalEmbedCalls).toBe(0);
|
||
expect(result.dryRun).toBe(true);
|
||
expect(result.would_embed).toBe(2);
|
||
expect(result.skipped).toBe(1);
|
||
expect(result.total_chunks).toBe(3);
|
||
expect(result.pages_processed).toBe(1);
|
||
});
|
||
|
||
test('non-dry-run path reports accurate embedded count (regression guard)', async () => {
|
||
const { runEmbedCore } = await import('../src/commands/embed.ts');
|
||
const chunksBySlug = new Map<string, any[]>([
|
||
['a', [{ chunk_index: 0, chunk_text: 'a', chunk_source: 'compiled_truth', embedded_at: null, token_count: 1 }]],
|
||
['b', [
|
||
{ chunk_index: 0, chunk_text: 'x', chunk_source: 'compiled_truth', embedded_at: null, token_count: 1 },
|
||
{ chunk_index: 1, chunk_text: 'y', chunk_source: 'compiled_truth', embedded_at: null, token_count: 1 },
|
||
]],
|
||
]);
|
||
// D5a: source_id + page_id required on StaleChunkRow.
|
||
const stale = [
|
||
{ slug: 'a', chunk_index: 0, chunk_text: 'a', chunk_source: 'compiled_truth' as const, model: null, token_count: 1, source_id: 'default', page_id: 1 },
|
||
{ slug: 'b', chunk_index: 0, chunk_text: 'x', chunk_source: 'compiled_truth' as const, model: null, token_count: 1, source_id: 'default', page_id: 2 },
|
||
{ slug: 'b', chunk_index: 1, chunk_text: 'y', chunk_source: 'compiled_truth' as const, model: null, token_count: 1, source_id: 'default', page_id: 2 },
|
||
];
|
||
|
||
const engine = mockEngine({
|
||
countStaleChunks: async () => 3,
|
||
listStaleChunks: async () => stale,
|
||
getChunks: async (slug: string) => chunksBySlug.get(slug) || [],
|
||
upsertChunks: async () => {},
|
||
});
|
||
|
||
process.env.GBRAIN_EMBED_CONCURRENCY = '2';
|
||
|
||
const result = await runEmbedCore(engine, { stale: true });
|
||
|
||
expect(result.dryRun).toBe(false);
|
||
expect(result.embedded).toBe(3); // 1 from a + 2 from b
|
||
expect(result.would_embed).toBe(0);
|
||
expect(result.pages_processed).toBe(2);
|
||
});
|
||
});
|
||
|
||
// ────────────────────────────────────────────────────────────────
|
||
// runEmbedCore --stale egress fix: SQL-side staleness filter
|
||
// Replaces the listPages + per-page getChunks bomb with a count +
|
||
// slug-grouped SELECT. On a 100%-embedded brain, 0 listPages calls.
|
||
// ────────────────────────────────────────────────────────────────
|
||
|
||
describe('runEmbedCore --stale egress fix (SQL-side filter)', () => {
|
||
test('zero stale chunks: countStaleChunks short-circuits, listPages never called', async () => {
|
||
const { runEmbedCore } = await import('../src/commands/embed.ts');
|
||
let listPagesCalled = false;
|
||
let getChunksCalled = false;
|
||
let listStaleCalled = false;
|
||
const engine = mockEngine({
|
||
countStaleChunks: async () => 0,
|
||
listPages: async () => { listPagesCalled = true; return []; },
|
||
getChunks: async () => { getChunksCalled = true; return []; },
|
||
listStaleChunks: async () => { listStaleCalled = true; return []; },
|
||
upsertChunks: async () => {},
|
||
});
|
||
|
||
const result = await runEmbedCore(engine, { stale: true });
|
||
|
||
expect(result.embedded).toBe(0);
|
||
expect(result.pages_processed).toBe(0);
|
||
// The egress fix: NONE of these should have been called when count=0.
|
||
expect(listPagesCalled).toBe(false);
|
||
expect(getChunksCalled).toBe(false);
|
||
expect(listStaleCalled).toBe(false);
|
||
expect(totalEmbedCalls).toBe(0);
|
||
});
|
||
|
||
test('N stale chunks across M pages: only stale slugs re-fetched, exact stale set embedded, non-stale chunks preserved', async () => {
|
||
const { runEmbedCore } = await import('../src/commands/embed.ts');
|
||
let listPagesCalled = false;
|
||
|
||
// D5a: source_id + page_id required on StaleChunkRow.
|
||
const stale = [
|
||
{ slug: 'page-a', chunk_index: 0, chunk_text: 'x', chunk_source: 'compiled_truth' as const, model: null, token_count: null, source_id: 'default', page_id: 1 },
|
||
{ slug: 'page-b', chunk_index: 1, chunk_text: 'y', chunk_source: 'compiled_truth' as const, model: null, token_count: null, source_id: 'default', page_id: 2 },
|
||
{ slug: 'page-b', chunk_index: 2, chunk_text: 'z', chunk_source: 'compiled_truth' as const, model: null, token_count: null, source_id: 'default', page_id: 2 },
|
||
];
|
||
// page-b has a FRESH chunk at index 0 that must be preserved through the upsert.
|
||
const fullChunks: Record<string, any[]> = {
|
||
'page-a': [
|
||
{ chunk_index: 0, chunk_text: 'x', chunk_source: 'compiled_truth', embedded_at: null, token_count: 1 },
|
||
],
|
||
'page-b': [
|
||
{ chunk_index: 0, chunk_text: 'fresh', chunk_source: 'compiled_truth', embedded_at: '2026-01-01', token_count: 5 },
|
||
{ chunk_index: 1, chunk_text: 'y', chunk_source: 'compiled_truth', embedded_at: null, token_count: 1 },
|
||
{ chunk_index: 2, chunk_text: 'z', chunk_source: 'compiled_truth', embedded_at: null, token_count: 1 },
|
||
],
|
||
};
|
||
const upsertCalls: Array<{ slug: string; chunks: any[] }> = [];
|
||
const engine = mockEngine({
|
||
countStaleChunks: async () => 3,
|
||
listStaleChunks: async () => stale,
|
||
listPages: async () => { listPagesCalled = true; return []; },
|
||
getChunks: async (slug: string) => fullChunks[slug] || [],
|
||
upsertChunks: async (slug: string, chunks: any[]) => { upsertCalls.push({ slug, chunks }); },
|
||
});
|
||
|
||
const result = await runEmbedCore(engine, { stale: true });
|
||
|
||
// listPages must NOT be called in the SQL-side path.
|
||
expect(listPagesCalled).toBe(false);
|
||
// One embedBatch call per stale slug (a, b).
|
||
expect(totalEmbedCalls).toBe(2);
|
||
expect(result.embedded).toBe(3);
|
||
expect(result.pages_processed).toBe(2);
|
||
|
||
// page-b's upsert MUST include the fresh chunk (chunk_index=0) — otherwise
|
||
// it would be deleted by the upsertChunks != ALL filter. Critical regression check.
|
||
const pageBUpsert = upsertCalls.find(u => u.slug === 'page-b');
|
||
expect(pageBUpsert).toBeDefined();
|
||
const freshChunkInUpsert = pageBUpsert!.chunks.find((c: any) => c.chunk_index === 0);
|
||
expect(freshChunkInUpsert).toBeDefined();
|
||
// Fresh chunk has no `embedding` field (preserved via COALESCE in upsertChunks SQL).
|
||
expect(freshChunkInUpsert.embedding).toBeUndefined();
|
||
// Previously-stale chunks come through WITH a new embedding.
|
||
const staleChunkInUpsert = pageBUpsert!.chunks.find((c: any) => c.chunk_index === 1);
|
||
expect(staleChunkInUpsert.embedding).toBeDefined();
|
||
expect(staleChunkInUpsert.embedding).toBeInstanceOf(Float32Array);
|
||
});
|
||
|
||
test('--stale dry-run: counts stale via countStaleChunks (no listStaleChunks call), no embedBatch or upsertChunks', async () => {
|
||
const { runEmbedCore } = await import('../src/commands/embed.ts');
|
||
// v0.33.3 cherry-pick contract: dry-run path uses countStaleChunks
|
||
// ONLY — it does not call listStaleChunks. The pre-flight count is
|
||
// what gets reported; pages_processed stays at 0 because we
|
||
// intentionally skip the cursor walk in dry-run.
|
||
let listStaleCalled = false;
|
||
const upserts: string[] = [];
|
||
const engine = mockEngine({
|
||
countStaleChunks: async () => 2,
|
||
listStaleChunks: async () => { listStaleCalled = true; return []; },
|
||
upsertChunks: async (slug: string) => { upserts.push(slug); },
|
||
});
|
||
|
||
const result = await runEmbedCore(engine, { stale: true, dryRun: true });
|
||
|
||
expect(totalEmbedCalls).toBe(0);
|
||
expect(upserts).toEqual([]);
|
||
expect(result.would_embed).toBe(2);
|
||
// Cheaper dry-run: skips the cursor walk entirely.
|
||
expect(listStaleCalled).toBe(false);
|
||
expect(result.pages_processed).toBe(0);
|
||
expect(result.dryRun).toBe(true);
|
||
});
|
||
|
||
test('--all (non-stale) path is byte-identical: walks listPages and embeds every chunk', async () => {
|
||
// Regression guard for the legacy --all path. Behavior must be byte-identical
|
||
// to pre-fix: listPages + per-page getChunks + embed every chunk.
|
||
const { runEmbedCore } = await import('../src/commands/embed.ts');
|
||
let countStaleCalled = false;
|
||
let listStaleCalled = false;
|
||
const pages = [{ slug: 'a' }, { slug: 'b' }];
|
||
const chunksBySlug = new Map<string, any[]>([
|
||
['a', [{ chunk_index: 0, chunk_text: 'a', chunk_source: 'compiled_truth', embedded_at: '2026-01-01', token_count: 1 }]],
|
||
['b', [{ chunk_index: 0, chunk_text: 'b', chunk_source: 'compiled_truth', embedded_at: null, token_count: 1 }]],
|
||
]);
|
||
|
||
const engine = mockEngine({
|
||
countStaleChunks: async () => { countStaleCalled = true; return 1; },
|
||
listStaleChunks: async () => { listStaleCalled = true; return []; },
|
||
listPages: async () => pages,
|
||
getChunks: async (slug: string) => chunksBySlug.get(slug) || [],
|
||
upsertChunks: async () => {},
|
||
});
|
||
|
||
const result = await runEmbedCore(engine, { all: true });
|
||
|
||
// --all path must NOT take the new short-circuit.
|
||
expect(countStaleCalled).toBe(false);
|
||
expect(listStaleCalled).toBe(false);
|
||
// Both pages get embedded, regardless of embedded_at — that's the --all contract.
|
||
expect(totalEmbedCalls).toBe(2);
|
||
expect(result.embedded).toBe(2);
|
||
});
|
||
});
|
||
|
||
// ────────────────────────────────────────────────────────────────
|
||
// D5: embedBatchWithBackoff retry wrapper — 8 cases per plan
|
||
// (D2 jitter, D4 cause-unwrap, D4a maxRetries:0 passthrough,
|
||
// D8 abortSignal threading, plus the pure helpers).
|
||
// ────────────────────────────────────────────────────────────────
|
||
|
||
describe('embedBatchWithBackoff (D2/D4/D4a/D8)', () => {
|
||
test('case 1: parses "try again in 248ms" form and retries', async () => {
|
||
const { embedBatchWithBackoff } = await import('../src/commands/embed.ts');
|
||
let calls = 0;
|
||
embedBatchBehavior = async () => {
|
||
calls++;
|
||
if (calls === 1) {
|
||
const err = new Error('Rate limit reached. Please try again in 50ms.');
|
||
(err as any).cause = { status: 429 };
|
||
throw err;
|
||
}
|
||
return [new Float32Array(1536)];
|
||
};
|
||
const result = await embedBatchWithBackoff(['x']);
|
||
expect(calls).toBe(2);
|
||
expect(result).toHaveLength(1);
|
||
});
|
||
|
||
test('case 2: parses "try again in 1.5s" form and retries', async () => {
|
||
const { embedBatchWithBackoff, parseRetryDelayMs, RATE_LIMIT_JITTER, RATE_LIMIT_PAD_MS } = await import('../src/commands/embed.ts');
|
||
let calls = 0;
|
||
embedBatchBehavior = async () => {
|
||
calls++;
|
||
if (calls === 1) {
|
||
const err = new Error('429 — please try again in 0.05s');
|
||
(err as any).cause = { status: 429 };
|
||
throw err;
|
||
}
|
||
return [new Float32Array(1536)];
|
||
};
|
||
const result = await embedBatchWithBackoff(['x']);
|
||
expect(calls).toBe(2);
|
||
expect(result).toHaveLength(1);
|
||
// Pure-helper sanity check on the "s" form path while we're here.
|
||
const delay = parseRetryDelayMs('try again in 1.5s', () => 0.5);
|
||
// 1.5s = 1500ms + 500ms pad = 2000ms; jitter at rng=0.5 → 1.0 multiplier.
|
||
const expected = (1500 + RATE_LIMIT_PAD_MS) * (1 + (0.5 * 2 - 1) * RATE_LIMIT_JITTER);
|
||
expect(delay).toBe(Math.floor(expected));
|
||
});
|
||
|
||
test('case 3: unparseable rate-limit message uses RATE_LIMIT_FALLBACK_MS', async () => {
|
||
const { parseRetryDelayMs, RATE_LIMIT_FALLBACK_MS, RATE_LIMIT_JITTER } = await import('../src/commands/embed.ts');
|
||
// Min delay = fallback × (1 - jitter); max = fallback × (1 + jitter).
|
||
const minExpected = Math.floor(RATE_LIMIT_FALLBACK_MS * (1 - RATE_LIMIT_JITTER));
|
||
const maxExpected = Math.floor(RATE_LIMIT_FALLBACK_MS * (1 + RATE_LIMIT_JITTER));
|
||
for (let i = 0; i < 20; i++) {
|
||
const d = parseRetryDelayMs('429 too many requests');
|
||
expect(d).toBeGreaterThanOrEqual(minExpected);
|
||
expect(d).toBeLessThanOrEqual(maxExpected);
|
||
}
|
||
});
|
||
|
||
test('case 4: non-rate-limit error rethrows immediately without retry', async () => {
|
||
const { embedBatchWithBackoff } = await import('../src/commands/embed.ts');
|
||
let calls = 0;
|
||
embedBatchBehavior = async () => {
|
||
calls++;
|
||
throw new Error('500 internal server error');
|
||
};
|
||
await expect(embedBatchWithBackoff(['x'])).rejects.toThrow('500 internal server error');
|
||
// Single attempt — no retries on non-429.
|
||
expect(calls).toBe(1);
|
||
});
|
||
|
||
test('case 5: jitter range — same parsed delay produces non-identical sleeps across runs', async () => {
|
||
const { parseRetryDelayMs } = await import('../src/commands/embed.ts');
|
||
const samples = new Set<number>();
|
||
for (let i = 0; i < 50; i++) {
|
||
samples.add(parseRetryDelayMs('try again in 100ms'));
|
||
}
|
||
// 50 random samples with ±30% jitter should yield many distinct values.
|
||
expect(samples.size).toBeGreaterThan(5);
|
||
});
|
||
|
||
test('case 6: wall-clock budget mid-batch wakes the retry sleep and cancels mid-fetch', async () => {
|
||
const { embedBatchWithBackoff } = await import('../src/commands/embed.ts');
|
||
const controller = new AbortController();
|
||
let calls = 0;
|
||
embedBatchBehavior = async (_texts, opts) => {
|
||
calls++;
|
||
// The wrapper MUST pass the abortSignal into the gateway opts.
|
||
expect((opts as { abortSignal?: AbortSignal } | undefined)?.abortSignal).toBe(controller.signal);
|
||
if (calls === 1) {
|
||
const err = new Error('Rate limit reached. Please try again in 5000ms.');
|
||
(err as any).cause = { status: 429 };
|
||
throw err;
|
||
}
|
||
return [new Float32Array(1536)];
|
||
};
|
||
// Fire the budget abort during the retry sleep — abortableSleep should
|
||
// wake up early instead of waiting the full 5000ms.
|
||
setTimeout(() => controller.abort(), 50);
|
||
const t0 = Date.now();
|
||
await expect(embedBatchWithBackoff(['x'], { abortSignal: controller.signal })).rejects.toThrow();
|
||
const elapsed = Date.now() - t0;
|
||
// Should exit within ~200ms, not the 5000ms+ the retry-after would suggest.
|
||
expect(elapsed).toBeLessThan(500);
|
||
});
|
||
|
||
test('case 7: AITransientError-shaped wrap with 429 cause triggers retry; 500 cause does not', async () => {
|
||
const { embedBatchWithBackoff, detect429FromCause } = await import('../src/commands/embed.ts');
|
||
|
||
// Pure helper checks first.
|
||
expect(detect429FromCause({ cause: { status: 429 } })).toBe(true);
|
||
expect(detect429FromCause({ cause: { statusCode: 429 } })).toBe(true);
|
||
expect(detect429FromCause({ cause: { status: 500 } })).toBe(false);
|
||
expect(detect429FromCause({ status: 500 })).toBe(false);
|
||
expect(detect429FromCause(undefined)).toBe(false);
|
||
expect(detect429FromCause(null)).toBe(false);
|
||
// Deep wrap (defensive — current normalizeAIError wraps once).
|
||
expect(detect429FromCause({ cause: { cause: { status: 429 } } })).toBe(true);
|
||
|
||
// End-to-end: 429 wrapped as AITransientError-like shape → retry.
|
||
// Use a small retry-after in the wrapper message so the parsed delay
|
||
// is fast (keeps the test under the 5s timeout). The fallback delay
|
||
// of 60s would otherwise dominate.
|
||
let calls = 0;
|
||
embedBatchBehavior = async () => {
|
||
calls++;
|
||
if (calls === 1) {
|
||
// Simulate normalizeAIError wrap: message has a parseable retry-after,
|
||
// status only on cause (the structural detection path under test).
|
||
const wrapper = new Error('try again in 10ms');
|
||
(wrapper as any).cause = { status: 429 };
|
||
throw wrapper;
|
||
}
|
||
return [new Float32Array(1536)];
|
||
};
|
||
const result = await embedBatchWithBackoff(['x']);
|
||
expect(calls).toBe(2);
|
||
expect(result).toHaveLength(1);
|
||
|
||
// 500 wrapped → no retry, rethrow immediately.
|
||
embedBatchBehavior = async () => {
|
||
const wrapper = new Error('AI transient error');
|
||
(wrapper as any).cause = { status: 500 };
|
||
throw wrapper;
|
||
};
|
||
await expect(embedBatchWithBackoff(['x'])).rejects.toThrow('AI transient error');
|
||
});
|
||
|
||
test('case 8: wrapper passes maxRetries:0 through to embedBatch (no SDK retry stack)', async () => {
|
||
const { embedBatchWithBackoff } = await import('../src/commands/embed.ts');
|
||
embedBatchBehavior = async () => [new Float32Array(1536)];
|
||
await embedBatchWithBackoff(['x']);
|
||
expect(lastEmbedBatchOpts).toBeDefined();
|
||
expect((lastEmbedBatchOpts as { maxRetries?: number }).maxRetries).toBe(0);
|
||
});
|
||
});
|
||
|
||
// ────────────────────────────────────────────────────────────────
|
||
// D5/D7: embedAllStale sourceId threading — invariant tests
|
||
// ────────────────────────────────────────────────────────────────
|
||
|
||
// ────────────────────────────────────────────────────────────────
|
||
// Gap scan: CLI flag wiring + end-to-end budget firing (beyond plan)
|
||
// ────────────────────────────────────────────────────────────────
|
||
|
||
describe('runEmbed CLI flag wiring (--stale --source)', () => {
|
||
test('--source <id> on CLI threads sourceId into countStaleChunks', async () => {
|
||
let receivedOpts: unknown;
|
||
const engine = mockEngine({
|
||
countStaleChunks: async (opts: unknown) => {
|
||
receivedOpts = opts;
|
||
return 0; // short-circuit so we don't hit listStaleChunks
|
||
},
|
||
});
|
||
await runEmbed(engine, ['--stale', '--source', 'media-corpus']);
|
||
expect(receivedOpts).toEqual({ sourceId: 'media-corpus' });
|
||
});
|
||
|
||
test('--stale without --source passes undefined opts (back-compat fast path)', async () => {
|
||
let receivedOpts: unknown;
|
||
const engine = mockEngine({
|
||
countStaleChunks: async (opts: unknown) => {
|
||
receivedOpts = opts;
|
||
return 0;
|
||
},
|
||
});
|
||
await runEmbed(engine, ['--stale']);
|
||
expect(receivedOpts).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
describe('embedAllStale wall-clock budget end-to-end (D3 + D3a)', () => {
|
||
test('GBRAIN_EMBED_TIME_BUDGET_MS=N cuts the outer loop short on stuck workers', async () => {
|
||
const { runEmbedCore } = await import('../src/commands/embed.ts');
|
||
// Tiny budget: 100ms. Each embed call sleeps 50ms; with budget + multiple
|
||
// small batches, the second listStaleChunks call should see the abort
|
||
// signal AND the worker loop should not claim further keys.
|
||
process.env.GBRAIN_EMBED_TIME_BUDGET_MS = '100';
|
||
process.env.GBRAIN_EMBED_CONCURRENCY = '1';
|
||
|
||
let listCallCount = 0;
|
||
let totalRowsReturned = 0;
|
||
// Return rows in chunks of 1 so the outer while-loop ticks frequently.
|
||
// 10 rows total across 10 "batches"; the budget should kill the loop
|
||
// partway through.
|
||
const allRows = Array.from({ length: 10 }, (_, i) => ({
|
||
slug: `b-${i}`,
|
||
chunk_index: 0,
|
||
chunk_text: `t${i}`,
|
||
chunk_source: 'compiled_truth' as const,
|
||
model: null,
|
||
token_count: 1,
|
||
source_id: 'default',
|
||
page_id: i + 1,
|
||
}));
|
||
|
||
const engine = mockEngine({
|
||
countStaleChunks: async () => allRows.length,
|
||
listStaleChunks: async (opts: { afterPageId?: number } = {}) => {
|
||
listCallCount++;
|
||
const startIdx = (opts.afterPageId ?? 0); // 0 means start
|
||
const idx = allRows.findIndex(r => r.page_id > startIdx);
|
||
if (idx === -1) return [];
|
||
const row = allRows[idx];
|
||
totalRowsReturned++;
|
||
return [row];
|
||
},
|
||
getChunks: async () => [],
|
||
upsertChunks: async () => {},
|
||
});
|
||
|
||
// embedBatch takes 80ms per call — budget exhausts after ~1 page.
|
||
embedBatchBehavior = async (texts) => {
|
||
await new Promise(r => setTimeout(r, 80));
|
||
return texts.map(() => new Float32Array(1536));
|
||
};
|
||
|
||
const t0 = Date.now();
|
||
const result = await runEmbedCore(engine, { stale: true });
|
||
const elapsed = Date.now() - t0;
|
||
|
||
// Should not have visited all 10 pages.
|
||
expect(result.pages_processed).toBeLessThan(10);
|
||
// Total wall-clock should be roughly the budget + the time for in-flight
|
||
// workers to drain (1 worker × 80ms latency). Generous upper bound: 1500ms.
|
||
expect(elapsed).toBeLessThan(1500);
|
||
});
|
||
});
|
||
|
||
describe('embedAllStale --source threading (D7)', () => {
|
||
test('countStaleChunks receives the sourceId opt', async () => {
|
||
const { runEmbedCore } = await import('../src/commands/embed.ts');
|
||
let receivedOpts: unknown;
|
||
const engine = mockEngine({
|
||
countStaleChunks: async (opts: unknown) => {
|
||
receivedOpts = opts;
|
||
return 0; // short-circuit
|
||
},
|
||
});
|
||
await runEmbedCore(engine, { stale: true, sourceId: 'media-corpus' });
|
||
expect(receivedOpts).toEqual({ sourceId: 'media-corpus' });
|
||
});
|
||
|
||
test('countStaleChunks receives undefined opts when --source omitted (back-compat)', async () => {
|
||
const { runEmbedCore } = await import('../src/commands/embed.ts');
|
||
let receivedOpts: unknown;
|
||
const engine = mockEngine({
|
||
countStaleChunks: async (opts: unknown) => {
|
||
receivedOpts = opts;
|
||
return 0;
|
||
},
|
||
});
|
||
await runEmbedCore(engine, { stale: true });
|
||
expect(receivedOpts).toBeUndefined();
|
||
});
|
||
|
||
test('listStaleChunks receives the sourceId in opts when running source-scoped', async () => {
|
||
const { runEmbedCore } = await import('../src/commands/embed.ts');
|
||
let firstCallOpts: unknown;
|
||
const stale = [
|
||
{ slug: 'p', chunk_index: 0, chunk_text: 'x', chunk_source: 'compiled_truth' as const, model: null, token_count: 1, source_id: 'media-corpus', page_id: 1 },
|
||
];
|
||
const engine = mockEngine({
|
||
countStaleChunks: async () => 1,
|
||
listStaleChunks: async (opts: unknown) => {
|
||
if (firstCallOpts === undefined) firstCallOpts = opts;
|
||
return stale;
|
||
},
|
||
getChunks: async () => stale.map(s => ({ chunk_index: s.chunk_index, chunk_text: s.chunk_text, chunk_source: s.chunk_source, embedded_at: null, token_count: 1 })),
|
||
upsertChunks: async () => {},
|
||
});
|
||
await runEmbedCore(engine, { stale: true, sourceId: 'media-corpus' });
|
||
expect((firstCallOpts as { sourceId?: string }).sourceId).toBe('media-corpus');
|
||
});
|
||
});
|