Compare commits

..
Author SHA1 Message Date
Garry TanandClaude Fable 5 60fb33c0d9 fix(embed): stop worker pool from dispatching new slices after a sub-batch failure
Review finding on #3130: when one sub-batch rejected, the surviving pool
workers kept draining ALL remaining slices in the background after
embedBatch had already rejected — real provider spend post-failure,
onBatchComplete firing after the caller handled the error, and stacked
429 pressure when embedBatchWithBackoff retried while the failed run was
still draining. A shared failed flag now stops further dispatch (in-flight
sibling calls still settle, bounded by concurrency-1) and suppresses
post-failure progress callbacks. Pinned by a new test: 10 slices /
concurrency 2 / first call fails → no calls after rejection, no
completions reported.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 11:52:30 -07:00
Garry TanandClaude Fable 5 11ed0871c2 test: fix CI red on #3130 — withEnv for batch-concurrency env + close resetGateway shard-order poison window
Two real failures surfaced by this PR's re-sharding:

1. verify/check-test-isolation: embed-batch-concurrency.test.ts mutated
   process.env directly (R1). Now uses withEnv().

2. test (9) source-health "expected 1280 dimensions, not 1536": a file
   whose last afterEach calls resetGateway() leaves the gateway slot
   empty during the NEXT file's beforeAll (which runs before any
   beforeEach can restore the legacy 1536 pin), so initSchema() sizes
   the embedding column from the zembed-1/1280 defaults and every
   1536-d fixture in that file fails. Which pair collides depends on
   shard composition, so adding test files (as this PR does) can
   surface it anywhere. The legacy-embedding preload now also repairs
   the empty slot in a global afterEach (preload after-hooks run after
   file-local ones), closing the window at the root instead of
   patching one victim file.

Reproduced locally with a poison/afterEach-reset file followed by a
schema-creating file: embedding column typmod 1280 before the fix,
1536 after. check-test-isolation, typecheck, and the affected suites
all pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 11:08:07 -07:00
Garry TanandClaude Fable 5 595eeb7d6f fix(embed): per-request batch caps (google/dashscope) + parallel batch dispatch (#970 #1199 #1207 #1818)
Four embedding-throughput/correctness fixes:

- #970: google recipe now declares max_batch_tokens (204,800 — derived
  from Gemini's real limits: 100 inputs per batchEmbedContents × 2048
  tokens per input) + max_batch_count 100 + chars_per_token, silencing
  the missing-cap startup warning and enabling the gateway pre-split.
  Deliberately NOT the 2048 per-input limit, which would over-split 50x.

- #1199: new optional EmbeddingTouchpoint.max_batch_count enforced in
  splitByTokenBudget (flush at N inputs even when the token budget has
  room); dashscope sets 10 (provider hard-caps embeddings at 10 inputs
  per request). isTokenLimitError also learns DashScope's
  "batch size is invalid" message so recursive halving backstops it.

- #1207: gbrain import without --workers now resolves through the shared
  autoConcurrency policy (PGLite → 1, >100 files on Postgres → 4)
  instead of hardcoding serial; explicit --workers still wins.

- #1818: embedBatch dispatches its 100-input sub-batches through a
  bounded worker pool (default 4; EmbedBatchOptions.concurrency /
  GBRAIN_EMBED_BATCH_CONCURRENCY override) with index-addressed results
  so output order is preserved; single-batch fast path unchanged.

Also: listRecipes() now reads the exported RECIPES map instead of the
private ALL array (one source of truth; lets tests inject a synthetic
capless recipe to keep the startup-warning path covered now that every
real recipe declares a cap).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:39:33 -07:00
27 changed files with 505 additions and 476 deletions
File diff suppressed because one or more lines are too long
-4
View File
@@ -85,10 +85,6 @@ gbrain get media/articles/<slug>
# 5. Cross-link entities
# For every person/company mentioned, add a timeline back-link.
# Mark NEW pages minted for article subjects the reader only reads ABOUT
# (not personal contacts) as reference: `gbrain reference <slug>`
# (or reference: true in frontmatter). Exempts them from coverage nudges;
# they stay searchable. Default for real contacts: do NOT set it.
```
## Quality bar
-7
View File
@@ -273,13 +273,6 @@ Cross-link entities mentioned in the analysis:
- For every person the right column references with a brain page, add a
back-link from `people/<slug>` to the new `media/books/<slug>-personalized`
page (per `conventions/quality.md` Iron Law).
- **Mark book figures as reference entities.** Any NEW person/company page you
mint for a figure from the book (an author, a historical figure, a company
the book discusses) — someone the reader reads ABOUT but doesn't personally
interact with — should be flagged: `gbrain reference <slug>` (or
`reference: true` in frontmatter). They stay fully searchable/linkable but
are exempt from coverage nudges (timeline/links). Skip this for anyone the
reader actually knows. Default for normal contacts: do NOT set it.
## Quality bar (the bar)
-62
View File
@@ -1,62 +0,0 @@
# Convention: Reference entities (canon figures)
Some person/company pages are people/orgs the user **reads about** but does not
personally interact with — a book's author, a historical figure, a company an
article discusses (Andy Grove, Kleiner Perkins, Intel…). They are real
knowledge, worth a page, but they have **no dated history in the user's own
life**, so the entity coverage metrics (`timeline_coverage`,
`entity_link_coverage`) flag them as permanently incomplete with no honest fix.
The `reference: true` frontmatter flag resolves this.
## What it does
- A page with `reference: true` is **exempt from the entity coverage metrics
only** (`timeline_coverage`, `entity_link_coverage`, and their onboard
nudges).
- It keeps its real `type` (`person` / `company`), so it stays **fully
searchable, enrichable, linkable, and edge-resolvable**. NOTHING about
retrieval changes — this is the whole reason it's a flag, not a new `type`.
- It is **opt-in**. Absent / `false` / anything-but-`true` = a normal entity
that DOES count toward coverage. **This is the default — do not set it on real
contacts.**
## When to set it
Set `reference: true` when the entity is a figure/org the user reads ABOUT, not
someone they deal with:
- authors and figures discussed in a book (book-mirror) or article
(article-enrichment)
- historical / canon figures imported as reference knowledge
- companies named only as examples in source material
Do NOT set it for people the user actually meets, emails, or works with — those
are normal entities whose missing timeline/links is a real, actionable gap.
## How to set it
```bash
gbrain reference <slug> # mark as reference
gbrain reference <slug> --unset # back to a normal entity
```
The command writes the flag to BOTH the markdown frontmatter (durable; survives
re-ingest / engine rebuild — markdown is the source of truth) AND the engine
JSONB (so coverage reflects it immediately, no re-sync). It's idempotent. You
can also hand-edit frontmatter (`reference: true`) and re-ingest.
## Why a flag, not a type
A new `type: reference-person` would drop the page out of every
`type IN ('person','company')` filter — search, enrichment, whoknows, link
inference — so you'd lose the figure everywhere, not just the metric. The flag
narrows the change to exactly the coverage denominators and nothing else.
## Implementation
`src/core/reference-flag.ts``referenceExclusionSql(alias?)` is the single
source of truth for the predicate `(frontmatter->>'reference') IS DISTINCT FROM
'true'`, ANDed into both numerator and denominator at every coverage site
(getHealth in both engines, onboard/checks.ts, init-nudge.ts). Backed by the GIN
index on `pages.frontmatter`.
-12
View File
@@ -268,18 +268,6 @@ Active items, pending decisions, things to track.
- **YYYY-MM-DD** | Event description [Source: ...]
```
### Reference entities (canon figures)
If the entity is someone/something the user reads ABOUT but does not personally
interact with — a book author, a historical figure, a company discussed in an
article — mark the page as reference: `gbrain reference <slug>` (or
`reference: true` in frontmatter). Reference pages keep their `person`/`company`
type and stay fully searchable, enrichable, and linkable; they are only exempt
from the entity coverage nudges (timeline/links) that don't apply to figures
with no dated history in the user's own life. **Default: do NOT set it** — real
people and companies the user deals with are normal entities. Full convention:
`conventions/reference-entities.md`.
### Step 7: Cross-reference
- Update company pages from person enrichment (and vice versa)
-5
View File
@@ -270,11 +270,6 @@ Populate them periodically or after major imports:
- `gbrain stats` — verify `link_count > 0` and `timeline_entry_count > 0` after extraction.
- `gbrain health` — review `link_coverage` and `timeline_coverage` percentages
on entity pages (person/company). Below 50% means more extraction is needed.
Note: pages flagged `reference: true` (canon/reference figures the user only
reads about) are EXEMPT from these two metrics — if coverage looks stuck
because of book/article-imported figures with no real history, mark them with
`gbrain reference <slug>` rather than chasing the percentage. See
`conventions/reference-entities.md`.
Available link types (use with `gbrain graph-query --type`):
`attended`, `works_at`, `invested_in`, `founded`, `advises`, `mentions`, `source`.
+1 -6
View File
@@ -54,7 +54,7 @@ export function bigintToStringReplacer(_key: string, value: unknown): unknown {
}
// CLI-only commands that bypass the operation layer
export const CLI_ONLY = new Set(['init', 'reinit-pglite', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'extract-conversation-facts', 'enrich', 'reference', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'sources', 'mounts', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'providers', 'storage', 'repos', 'code-def', 'code-refs', 'reindex', 'reindex-code', 'reindex-frontmatter', 'code-callers', 'code-callees', 'reconcile-links', 'frontmatter', 'auth', 'friction', 'claw-test', 'book-mirror', 'takes', 'think', 'salience', 'anomalies', 'calibration', 'transcripts', 'models', 'remote', 'recall', 'forget', 'edges-backfill', 'cache', 'ze-switch', 'founder', 'brainstorm', 'lsd', 'schema', 'capture', 'onboard', 'conversation-parser', 'status', 'connect', 'skillopt', 'quarantine', 'self-upgrade', 'advisor', 'watch', 'reindex-search-vector']);
export const CLI_ONLY = new Set(['init', 'reinit-pglite', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'extract-conversation-facts', 'enrich', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'sources', 'mounts', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'providers', 'storage', 'repos', 'code-def', 'code-refs', 'reindex', 'reindex-code', 'reindex-frontmatter', 'code-callers', 'code-callees', 'reconcile-links', 'frontmatter', 'auth', 'friction', 'claw-test', 'book-mirror', 'takes', 'think', 'salience', 'anomalies', 'calibration', 'transcripts', 'models', 'remote', 'recall', 'forget', 'edges-backfill', 'cache', 'ze-switch', 'founder', 'brainstorm', 'lsd', 'schema', 'capture', 'onboard', 'conversation-parser', 'status', 'connect', 'skillopt', 'quarantine', 'self-upgrade', 'advisor', 'watch', 'reindex-search-vector']);
// CLI-only commands whose handlers print their own --help text. These are
// excluded from the generic short-circuit so detailed per-command and
// per-subcommand usage stays reachable.
@@ -1652,11 +1652,6 @@ async function handleCliOnly(command: string, args: string[]) {
await runFiles(engine, args);
break;
}
case 'reference': {
const { runReference } = await import('./commands/reference.ts');
await runReference(engine, args);
break;
}
case 'embed': {
const { runEmbed } = await import('./commands/embed.ts');
await runEmbed(engine, args);
+10 -5
View File
@@ -170,10 +170,14 @@ export async function runImport(
// v0.22.13 (PR #490 Q2): shared parseWorkers helper rejects bad input
// (--workers 0, -3, "foo") with a loud error instead of silently falling
// through to 1. Mirrors sync.ts's flag handling.
const { parseWorkers } = await import('../core/sync-concurrency.ts');
let workerCount: number;
const { parseWorkers, autoConcurrency } = await import('../core/sync-concurrency.ts');
// #1207: undefined (no --workers flag) defers to autoConcurrency below —
// the shared sync/import policy (PGLite → 1, >100 files → 4) — instead of
// hardcoding serial. Large Postgres imports stop paying one embedding
// round-trip per file in sequence.
let workerCount: number | undefined;
try {
workerCount = parseWorkers(workersArg ?? undefined) ?? 1;
workerCount = parseWorkers(workersArg ?? undefined);
} catch (e) {
console.error(e instanceof Error ? e.message : String(e));
process.exit(1);
@@ -252,8 +256,9 @@ export async function runImport(
}
const files = resumeFilter(allFiles, dir, completed);
// Determine actual worker count
const actualWorkers = workerCount > 1 ? workerCount : 1;
// Determine actual worker count. Explicit --workers wins; otherwise the
// shared autoConcurrency policy decides from engine kind + file count.
const actualWorkers = autoConcurrency(engine, files.length, workerCount);
if (actualWorkers > 1) {
console.log(`Using ${actualWorkers} parallel workers`);
}
-130
View File
@@ -1,130 +0,0 @@
// gbrain reference <slug> [--unset] [--brain <dir>] [--json]
//
// Mark (or unmark) a page as a reference-only entity. A reference page keeps its
// real type (person/company) — fully searchable/enrichable/linkable — but is
// exempt from the entity coverage metrics (timeline_coverage,
// entity_link_coverage). See src/core/reference-flag.ts for the rationale.
//
// Durability: the flag is written to BOTH the markdown frontmatter (source of
// truth; survives re-ingest / engine rebuild) AND the engine `pages.frontmatter`
// JSONB (so the metric reflects it immediately, no re-sync needed).
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { isAbsolute, join, resolve } from 'node:path';
import { setCliExitVerdict } from '../core/cli-force-exit.ts';
import type { BrainEngine } from '../core/engine.ts';
import { REFERENCE_FRONTMATTER_KEY } from '../core/reference-flag.ts';
import { resolveSourceId } from '../core/source-resolver.ts';
/** Insert/replace/remove `reference: true` in a markdown frontmatter block.
* Minimal-diff: only the one line changes; key order is otherwise preserved. */
export function applyReferenceFrontmatter(content: string, on: boolean): string {
const keyLine = `${REFERENCE_FRONTMATTER_KEY}: true`;
const block = content.match(/^---\n([\s\S]*?)\n---/);
if (!block) {
// No frontmatter. Nothing to remove; if setting, prepend a block.
if (!on) return content;
return `---\n${keyLine}\n---\n\n${content}`;
}
let fm = block[1];
const hasKey = new RegExp(`^${REFERENCE_FRONTMATTER_KEY}:.*$`, 'm').test(fm);
if (on) {
fm = hasKey
? fm.replace(new RegExp(`^${REFERENCE_FRONTMATTER_KEY}:.*$`, 'm'), keyLine)
: `${fm}\n${keyLine}`;
} else {
if (!hasKey) return content;
fm = fm.replace(new RegExp(`^${REFERENCE_FRONTMATTER_KEY}:.*$\\n?`, 'm'), '');
}
// Function replacement so YAML chars ($, & …) in fm aren't treated as
// replacement patterns.
return content.replace(/^---\n[\s\S]*?\n---/, () => `---\n${fm}\n---`);
}
function parseArgs(args: string[]): { slug?: string; unset: boolean; json: boolean; brain?: string; source?: string } {
let slug: string | undefined;
let unset = false;
let json = false;
let brain: string | undefined;
let source: string | undefined;
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === '--unset') unset = true;
else if (a === '--json') json = true;
else if (a === '--brain' || a === '--dir') brain = args[++i];
else if (a === '--source') source = args[++i];
else if (!a.startsWith('--') && !slug) slug = a;
}
return { slug, unset, json, brain, source };
}
async function resolveBrainDir(engine: BrainEngine, explicit?: string): Promise<string | null> {
if (explicit) return resolve(explicit);
const configured = await engine.getConfig('sync.repo_path');
if (configured && existsSync(configured)) return resolve(configured);
return null;
}
export async function runReference(engine: BrainEngine, args: string[]): Promise<void> {
const { slug, unset, json, brain, source } = parseArgs(args);
if (!slug) {
console.error('Usage: gbrain reference <slug> [--unset] [--source <id>] [--brain <dir>] [--json]');
setCliExitVerdict(2);
return;
}
const brainDir = await resolveBrainDir(engine, brain);
if (!brainDir) {
console.error('reference: could not resolve brain dir. Pass --brain <dir> or set sync.repo_path.');
setCliExitVerdict(1);
return;
}
const rel = slug.endsWith('.md') ? slug : `${slug}.md`;
const filePath = isAbsolute(rel) ? rel : join(brainDir, rel);
if (!existsSync(filePath)) {
console.error(`reference: page not found on disk: ${filePath}`);
setCliExitVerdict(1);
return;
}
// 1) Durable: edit the markdown frontmatter.
const before = readFileSync(filePath, 'utf8');
const after = applyReferenceFrontmatter(before, !unset);
const fileChanged = after !== before;
if (fileChanged) writeFileSync(filePath, after, 'utf8');
// 2) Immediate: update the engine frontmatter JSONB so the metric reflects it
// without waiting for a re-sync. Scoped to (source_id, slug) — slug is only
// unique per source, so a bare-slug UPDATE would clobber same-named pages
// in every other source. Resolve the source from the brain dir (tier-4
// local_path match) unless --source / GBRAIN_SOURCE overrides.
const cleanSlug = slug.replace(/\.md$/, '');
const sourceId = await resolveSourceId(engine, source ?? null, brainDir);
if (unset) {
await engine.executeRaw(
`UPDATE pages SET frontmatter = frontmatter - '${REFERENCE_FRONTMATTER_KEY}' WHERE slug = $1 AND source_id = $2`,
[cleanSlug, sourceId],
);
} else {
await engine.executeRaw(
`UPDATE pages SET frontmatter = jsonb_set(COALESCE(frontmatter, '{}'::jsonb), '{${REFERENCE_FRONTMATTER_KEY}}', 'true'::jsonb) WHERE slug = $1 AND source_id = $2`,
[cleanSlug, sourceId],
);
}
const result = { slug: cleanSlug, source_id: sourceId, reference: !unset, file_changed: fileChanged, file: filePath };
if (json) {
console.log(JSON.stringify(result));
} else {
const verb = unset ? 'unmarked' : 'marked';
console.log(`${verb} ${cleanSlug} as reference=${!unset}${fileChanged ? '' : ' (frontmatter already current)'}`);
if (!unset) {
console.log(' → exempt from timeline_coverage / entity_link_coverage; still fully searchable & linkable.');
}
}
}
+24 -6
View File
@@ -1513,12 +1513,21 @@ export async function embed(texts: string[], opts?: EmbedOpts): Promise<Float32A
const embedding = recipe.touchpoints?.embedding;
const maxBatchTokens = embedding?.max_batch_tokens;
const maxBatchCount = embedding?.max_batch_count;
const charsPerToken = embedding?.chars_per_token ?? DEFAULT_CHARS_PER_TOKEN;
// Pre-split is gated on max_batch_tokens. Recipes without it (e.g. OpenAI)
// ride the fast path: one embedMany call, no recursion safety net.
const batches = maxBatchTokens
? splitByTokenBudget(truncated, Math.floor(maxBatchTokens * effectiveSafetyFactor(recipe)), charsPerToken)
// Pre-split is gated on max_batch_tokens / max_batch_count. Recipes with
// neither (e.g. OpenAI) ride the fast path: one embedMany call, no
// recursion safety net.
const batches = (maxBatchTokens || maxBatchCount)
? splitByTokenBudget(
truncated,
maxBatchTokens
? Math.floor(maxBatchTokens * effectiveSafetyFactor(recipe))
: Number.MAX_SAFE_INTEGER,
charsPerToken,
maxBatchCount,
)
: [truncated];
const allEmbeddings: Float32Array[] = [];
@@ -1568,6 +1577,9 @@ export async function embed(texts: string[], opts?: EmbedOpts): Promise<Float32A
* responsible for applying any safety-factor shrink before passing in.
* @param charsPerToken - Provider-specific character density. Defaults to
* `DEFAULT_CHARS_PER_TOKEN` (4) when omitted, matching OpenAI tiktoken.
* @param maxBatchCount - #1199: optional cap on INPUTS per sub-batch, for
* providers that reject batches by count (DashScope: 10). When omitted,
* only the token budget governs.
*
* @internal exported for tests; not part of the public gateway API.
*/
@@ -1575,15 +1587,17 @@ export function splitByTokenBudget(
texts: string[],
budgetTokens: number,
charsPerToken: number = DEFAULT_CHARS_PER_TOKEN,
maxBatchCount?: number,
): string[][] {
const ratio = charsPerToken > 0 ? charsPerToken : DEFAULT_CHARS_PER_TOKEN;
const maxCount = maxBatchCount !== undefined && maxBatchCount > 0 ? maxBatchCount : Infinity;
const batches: string[][] = [];
let current: string[] = [];
let currentTokens = 0;
for (const text of texts) {
const estTokens = Math.ceil(text.length / ratio);
if (current.length > 0 && currentTokens + estTokens > budgetTokens) {
if (current.length > 0 && (currentTokens + estTokens > budgetTokens || current.length >= maxCount)) {
batches.push(current);
current = [];
currentTokens = 0;
@@ -1609,7 +1623,11 @@ export function isTokenLimitError(err: unknown): boolean {
/token.*limit.*exceeded/i.test(msg) ||
// OpenAI embeddings: "Invalid 'input': maximum request size is 300000 tokens per request."
/maximum request size.*tokens/i.test(msg) ||
/max.*tokens.*per.*request/i.test(msg)
/max.*tokens.*per.*request/i.test(msg) ||
// DashScope: "batch size is invalid, it should not be larger than 10." (#1199)
// Count-cap error, but recursive halving shrinks count too, so the same
// safety net converges.
/batch size is invalid/i.test(msg)
);
}
+4
View File
@@ -31,6 +31,10 @@ export const dashscope: Recipe = {
// path. Conservative declaration so the gateway pre-splits before
// hitting whatever undocumented server-side limit exists.
max_batch_tokens: 8192,
// #1199: DashScope hard-caps embeddings at 10 inputs per request
// ("batch size is invalid, it should not be larger than 10"). The
// token budget alone admits far more than 10 short chunks per batch.
max_batch_count: 10,
// text-embedding-v3 mixes English + CJK heavily; the tokenizer is
// closer to Voyage density than OpenAI tiktoken for CJK-dominant
// content. Conservative chars_per_token=2 leaves headroom.
+9
View File
@@ -16,6 +16,15 @@ export const google: Recipe = {
dims_options: [768, 1536, 3072],
cost_per_1m_tokens_usd: 0.15,
price_last_verified: '2026-04-20',
// #970: Gemini's documented limits are per-INPUT (2048 tokens,
// silently truncated beyond) and per-REQUEST count (batchEmbedContents
// caps at 100 inputs). There is no separate per-request token cap, so
// the token budget is derived: 100 inputs × 2048 tokens. The count cap
// binds first for typical chunk sizes. Do NOT copy the 2048 per-input
// limit into max_batch_tokens — that would over-split 50×.
max_batch_tokens: 204_800,
chars_per_token: 4,
max_batch_count: 100,
},
expansion: {
models: ['gemini-2.0-flash', 'gemini-2.0-flash-lite'],
+4 -1
View File
@@ -58,5 +58,8 @@ export function getRecipe(id: string): Recipe | undefined {
}
export function listRecipes(): Recipe[] {
return [...ALL];
// Read the map (not ALL) so there is one source of truth — getRecipe,
// model-resolver, and listRecipes all see the same registry, and tests
// can inject a synthetic recipe via RECIPES to exercise registry walks.
return [...RECIPES.values()];
}
+10
View File
@@ -46,6 +46,16 @@ export interface EmbeddingTouchpoint {
* Only consulted when `max_batch_tokens` is also set.
*/
chars_per_token?: number;
/**
* #1199: maximum number of INPUTS per embedding request, for providers
* that hard-cap batch size by count rather than (or in addition to)
* tokens — DashScope text-embedding-v3 rejects batches > 10 with
* `InvalidParameter`, Gemini batchEmbedContents caps at 100 requests.
* When set, the gateway's pre-split flushes a sub-batch at this count
* even if the token budget still has room. Independent of
* `max_batch_tokens`; either alone triggers the pre-split.
*/
max_batch_count?: number;
/**
* Budget-utilization ceiling in (0, 1]. The gateway pre-splits at
* `safety_factor × max_batch_tokens` to leave headroom for tokenizer
+56 -6
View File
@@ -79,15 +79,34 @@ export interface EmbedBatchOptions {
* and amplify rate-limit pressure.
*/
maxRetries?: number;
/**
* #1818: bounded parallelism across BATCH_SIZE sub-batches. Defaults to
* `GBRAIN_EMBED_BATCH_CONCURRENCY` env, else 4. Results are
* index-addressed so output order always matches input order. Set 1 to
* force the pre-v0.42 serial dispatch.
*/
concurrency?: number;
}
/**
* Embed a batch of texts via the gateway. Sub-batches of 100 so upstream
* progress callbacks fire incrementally on large imports. The gateway owns
* adaptive batch splitting and per-recipe token-budget logic; this paginator
* is purely about progress-callback granularity.
* owns progress-callback granularity and (#1818) bounded parallel dispatch
* of the sub-batches — the embed-stale.ts worker-pool pattern, scoped down.
*/
const BATCH_SIZE = 100;
const DEFAULT_EMBED_BATCH_CONCURRENCY = 4;
function resolveEmbedBatchConcurrency(options: EmbedBatchOptions): number {
if (options.concurrency !== undefined) {
return Math.max(1, Math.floor(options.concurrency));
}
const env = Number(process.env.GBRAIN_EMBED_BATCH_CONCURRENCY);
if (Number.isFinite(env) && env >= 1) return Math.floor(env);
return DEFAULT_EMBED_BATCH_CONCURRENCY;
}
export async function embedBatch(
texts: string[],
options: EmbedBatchOptions = {},
@@ -103,13 +122,44 @@ export async function embedBatch(
if (texts.length <= BATCH_SIZE && !options.onBatchComplete) {
return gatewayEmbed(texts, gwOpts);
}
const results: Float32Array[] = [];
// #1818: dispatch sub-batches through a bounded worker pool instead of a
// serial loop. Results are written into a preallocated index-addressed
// array so output order matches input order regardless of completion
// order; onBatchComplete reports a monotonic completed-embedding count.
const slices: Array<{ start: number; texts: string[] }> = [];
for (let i = 0; i < texts.length; i += BATCH_SIZE) {
const slice = texts.slice(i, i + BATCH_SIZE);
const out = await gatewayEmbed(slice, gwOpts);
results.push(...out);
options.onBatchComplete?.(results.length, texts.length);
slices.push({ start: i, texts: texts.slice(i, i + BATCH_SIZE) });
}
const results = new Array<Float32Array>(texts.length);
let next = 0;
let done = 0;
const numWorkers = Math.min(resolveEmbedBatchConcurrency(options), slices.length);
// Once any sub-batch fails, `failed` stops the surviving workers from
// dispatching FURTHER slices — the whole call is rejecting anyway, so
// continuing would burn real provider spend in the background and fire
// onBatchComplete after the caller already saw the failure (worst with
// embedBatchWithBackoff, whose 429 backoff assumes nothing is in flight).
// In-flight sibling calls still run to completion (bounded by numWorkers-1).
let failed = false;
const worker = async (): Promise<void> => {
while (!failed && next < slices.length) {
// NOTE: no local aborted-check here — an aborted signal makes the next
// gatewayEmbed call throw (SDK-side), which rejects the pool. Returning
// silently instead would resolve with holes in `results`.
const slice = slices[next++];
let out: Float32Array[];
try {
out = await gatewayEmbed(slice.texts, gwOpts);
} catch (err) {
failed = true;
throw err;
}
for (let j = 0; j < out.length; j++) results[slice.start + j] = out[j];
done += out.length;
if (!failed) options.onBatchComplete?.(done, texts.length);
}
};
await Promise.all(Array.from({ length: numWorkers }, () => worker()));
return results;
}
+2 -7
View File
@@ -18,7 +18,6 @@
import type { BrainEngine } from '../engine.ts';
import type { RemediationStep } from '../remediation-step.ts';
import { makeRemediationStep } from '../remediation-step.ts';
import { referenceExclusionSql } from '../reference-flag.ts';
/** Shared shape returned by all four checks. */
export interface OnboardCheckResult {
@@ -121,8 +120,7 @@ export async function checkEntityLinkCoverage(
engine,
`SELECT COUNT(*) AS count FROM pages
WHERE type IN ('person', 'company', 'organization', 'entity')
AND deleted_at IS NULL
AND ${referenceExclusionSql()}`,
AND deleted_at IS NULL`,
);
if (totalEntities === 0) {
@@ -146,7 +144,6 @@ export async function checkEntityLinkCoverage(
SELECT p.id FROM pages p ${sampleClause}
WHERE p.type IN ('person', 'company', 'organization', 'entity')
AND p.deleted_at IS NULL
AND ${referenceExclusionSql('p')}
AND EXISTS (SELECT 1 FROM links l WHERE l.to_page_id = p.id)
) sub`,
);
@@ -218,8 +215,7 @@ export async function checkTimelineCoverage(
engine,
`SELECT COUNT(*) AS count FROM pages
WHERE type IN ('person', 'company', 'organization', 'entity')
AND deleted_at IS NULL
AND ${referenceExclusionSql()}`,
AND deleted_at IS NULL`,
);
if (totalEntities === 0) {
@@ -241,7 +237,6 @@ export async function checkTimelineCoverage(
SELECT p.id FROM pages p ${sampleClause}
WHERE p.type IN ('person', 'company', 'organization', 'entity')
AND p.deleted_at IS NULL
AND ${referenceExclusionSql('p')}
AND EXISTS (SELECT 1 FROM timeline_entries t WHERE t.page_id = p.id)
) sub`,
);
+1 -5
View File
@@ -14,7 +14,6 @@
// also short-circuits (CI/scripted callers see nothing).
import type { BrainEngine } from '../engine.ts';
import { referenceExclusionSql } from '../reference-flag.ts';
const NUDGE_BUDGET_MS = 3000;
@@ -58,8 +57,7 @@ export async function runInitNudge(engine: BrainEngine): Promise<void> {
engine.executeRaw<{ count: string | number }>(
`SELECT COUNT(*) AS count FROM pages
WHERE type IN ('person', 'company', 'organization', 'entity')
AND deleted_at IS NULL
AND ${referenceExclusionSql()}`,
AND deleted_at IS NULL`,
[],
{ signal: controller.signal },
),
@@ -67,7 +65,6 @@ export async function runInitNudge(engine: BrainEngine): Promise<void> {
`SELECT COUNT(*) AS count FROM pages p
WHERE p.type IN ('person', 'company', 'organization', 'entity')
AND p.deleted_at IS NULL
AND ${referenceExclusionSql('p')}
AND EXISTS (SELECT 1 FROM links l WHERE l.to_page_id = p.id)`,
[],
{ signal: controller.signal },
@@ -76,7 +73,6 @@ export async function runInitNudge(engine: BrainEngine): Promise<void> {
`SELECT COUNT(*) AS count FROM pages p
WHERE p.type IN ('person', 'company', 'organization', 'entity')
AND p.deleted_at IS NULL
AND ${referenceExclusionSql('p')}
AND EXISTS (SELECT 1 FROM timeline_entries t WHERE t.page_id = p.id)`,
[],
{ signal: controller.signal },
-2
View File
@@ -23,7 +23,6 @@ import { runMigrations } from './migrate.ts';
import { PGLITE_SCHEMA_SQL, getPGLiteSchema } from './pglite-schema.ts';
import { DEFAULT_EMBEDDING_MODEL, DEFAULT_EMBEDDING_DIMENSIONS } from './ai/defaults.ts';
import { DELETE_BATCH_SIZE } from './engine-constants.ts';
import { referenceExclusionSql } from './reference-flag.ts';
import { MARKDOWN_CHUNKER_VERSION } from './chunkers/recursive.ts';
import { acquireLock, releaseLock, type LockHandle } from './pglite-lock.ts';
import { getFtsLanguage } from './fts-language.ts';
@@ -5203,7 +5202,6 @@ export class PGLiteEngine implements BrainEngine {
const { rows: [h] } = await this.db.query(`
WITH entity_pages AS (
SELECT id, slug FROM pages WHERE type IN ('person', 'company')
AND ${referenceExclusionSql()}
)
SELECT
(SELECT count(*) FROM pages) as page_count,
-5
View File
@@ -5320,12 +5320,7 @@ export class PostgresEngine implements BrainEngine {
// is working as intended, not an orphan.
const [h] = await sql`
WITH entity_pages AS (
-- reference:true pages are exempt from coverage metrics.
-- Inlined (postgres.js tagged-template interpolation = bound param,
-- not raw SQL); keep in sync with referenceExclusionSql() in
-- reference-flag.ts.
SELECT id, slug FROM pages WHERE type IN ('person', 'company')
AND (frontmatter->>'reference') IS DISTINCT FROM 'true'
)
SELECT
(SELECT count(*) FROM pages) as page_count,
-37
View File
@@ -1,37 +0,0 @@
// Reference / canon entities.
//
// A page with frontmatter `reference: true` is a reference-only entity — a
// figure or organization imported from a book / article / external source that
// the user reads ABOUT but does not actively interact with (e.g. Andy Grove,
// Kleiner Perkins). It keeps its real `type` (`person` / `company`), so it stays
// fully searchable, enrichable, linkable, and edge-resolvable — NOTHING about
// retrieval changes.
//
// The ONLY behavior it opts out of is the entity *coverage* metrics
// (`timeline_coverage`, `entity_link_coverage` + their onboard-nudge mirrors).
// Those metrics nudge "this entity should accumulate dated history / inbound
// links"; that assumption is right for people you actually deal with and wrong
// for canon imports, which have no dated events in the user's life. Excluding
// them keeps the metric honest and actionable instead of permanently red.
//
// Opt-in: absent / false / anything-but-true = normal entity (the default).
// Set via `gbrain reference <slug>` (or hand-edit frontmatter).
export const REFERENCE_FRONTMATTER_KEY = 'reference';
/**
* SQL predicate (true for NON-reference pages) to AND into entity-coverage
* denominators AND numerators so the ratio stays consistent. JSONB `->>` yields
* text; `IS DISTINCT FROM 'true'` treats absent (NULL) and every non-true value
* as a normal counted entity. Backed by the GIN index on `pages.frontmatter`.
*
* @param alias optional table alias (e.g. 'p' for `pages p`); omit for bare `pages`.
*
* NOTE: postgres-engine.ts uses a postgres.js tagged template where `${}` is a
* bound parameter, not raw SQL, so it inlines this predicate literally — keep
* the two in sync.
*/
export function referenceExclusionSql(alias = ''): string {
const col = alias ? `${alias}.frontmatter` : 'frontmatter';
return `(${col}->>'${REFERENCE_FRONTMATTER_KEY}') IS DISTINCT FROM 'true'`;
}
+109 -5
View File
@@ -39,6 +39,8 @@ import {
__getShrinkStateForTests,
} from '../../src/core/ai/gateway.ts';
import { AIConfigError, AITransientError } from '../../src/core/ai/errors.ts';
import { RECIPES } from '../../src/core/ai/recipes/index.ts';
import type { Recipe } from '../../src/core/ai/types.ts';
// The last test in this file leaves the gateway configured with a remote
// provider + fake key and a REAL embed transport. Without a final reset,
@@ -93,6 +95,14 @@ function configureGoogle(): void {
});
}
function configureDashscope(): void {
configureGateway({
embedding_model: 'dashscope:text-embedding-v3',
embedding_dimensions: 1024,
env: { DASHSCOPE_API_KEY: 'sk-fake' },
});
}
// --------- 1. Pure helpers ---------
describe('splitByTokenBudget (pure helper)', () => {
@@ -149,6 +159,27 @@ describe('splitByTokenBudget (pure helper)', () => {
expect(splitByTokenBudget(texts, 96_000, 0)).toEqual(splitByTokenBudget(texts, 96_000, 4));
expect(splitByTokenBudget(texts, 96_000, -1)).toEqual(splitByTokenBudget(texts, 96_000, 4));
});
// #1199: count cap for providers that reject batches by input count.
test('max_batch_count flushes even when token budget has room', () => {
const texts = Array.from({ length: 25 }, (_, i) => `t${i}`);
const result = splitByTokenBudget(texts, 1_000_000, 4, 10);
expect(result.map(b => b.length)).toEqual([10, 10, 5]);
expect(result.flat()).toEqual(texts);
});
test('token budget still governs alongside max_batch_count', () => {
const texts = ['a'.repeat(50_000), 'b'.repeat(50_000), 'c'.repeat(50_000)];
const result = splitByTokenBudget(texts, 96_000, 1, 10);
expect(result).toHaveLength(3);
});
test('undefined / zero / negative max_batch_count is ignored', () => {
const texts = Array.from({ length: 25 }, () => 'x');
expect(splitByTokenBudget(texts, 1_000_000, 4, undefined)).toHaveLength(1);
expect(splitByTokenBudget(texts, 1_000_000, 4, 0)).toHaveLength(1);
expect(splitByTokenBudget(texts, 1_000_000, 4, -5)).toHaveLength(1);
});
});
describe('isTokenLimitError (pure helper)', () => {
@@ -179,6 +210,12 @@ describe('isTokenLimitError (pure helper)', () => {
expect(isTokenLimitError(new Error('Exceeded 300000 max tokens per request'))).toBe(true);
});
test('matches DashScope batch-count error (#1199)', () => {
expect(isTokenLimitError(new Error(
'InvalidParameter: batch size is invalid, it should not be larger than 10.',
))).toBe(true);
});
test('does not match unrelated errors', () => {
expect(isTokenLimitError(new Error('Connection refused'))).toBe(false);
expect(isTokenLimitError(new Error('Invalid API key'))).toBe(false);
@@ -387,26 +424,92 @@ describe('shrink-on-miss adaptive cache', () => {
});
});
// --------- 8. Pre-split count cap through public embed() (#1199 / #970) ---------
describe('embed() pre-split honors max_batch_count', () => {
beforeEach(() => resetGateway());
afterEach(() => __setEmbedTransportForTests(null));
test('dashscope never dispatches more than 10 inputs per call (#1199)', async () => {
configureDashscope();
const stub = mock(async ({ values }: { values: string[] }) => fakeEmbeddings(values, 1024));
__setEmbedTransportForTests(stub as any);
// 25 short texts fit trivially in the 8192-token budget; without the
// count cap they'd ship as ONE batch and DashScope would reject it.
const texts = Array.from({ length: 25 }, (_, i) => `short-${i}`);
const result = await embed(texts);
expect(result).toHaveLength(25);
const callLengths = stub.mock.calls.map(([arg]) => (arg as { values: string[] }).values.length);
expect(Math.max(...callLengths)).toBeLessThanOrEqual(10);
expect(callLengths.reduce((a, b) => a + b, 0)).toBe(25);
// Order preserved across sub-batches.
expect((stub.mock.calls[0][0] as { values: string[] }).values[0]).toBe('short-0');
});
test('google pre-splits at 100 inputs per batchEmbedContents call (#970)', async () => {
configureGoogle();
const stub = mock(async ({ values }: { values: string[] }) => fakeEmbeddings(values, 768));
__setEmbedTransportForTests(stub as any);
const texts = Array.from({ length: 250 }, (_, i) => `g${i}`);
const result = await embed(texts);
expect(result).toHaveLength(250);
const callLengths = stub.mock.calls.map(([arg]) => (arg as { values: string[] }).values.length);
expect(callLengths).toEqual([100, 100, 50]);
});
});
// --------- 7. Startup warning (D9-B) ---------
describe('startup warning for recipes missing max_batch_tokens', () => {
beforeEach(() => resetGateway());
// #970 closed google's missing cap, so no registered recipe is capless
// anymore. Inject a synthetic capless recipe to keep the warning path
// covered for the NEXT recipe that forgets the field.
const caplessRecipe: Recipe = {
id: 'capless-test',
name: 'Capless Test Provider',
tier: 'openai-compat',
implementation: 'openai-compatible',
base_url_default: 'https://example.invalid/v1',
auth_env: { required: [] },
touchpoints: {
embedding: { models: ['capless-embed-1'], default_dims: 768 },
},
};
function configureCapless(): void {
configureGateway({
embedding_model: 'capless-test:capless-embed-1',
embedding_dimensions: 768,
env: {},
});
}
test('configured missing-cap recipe warns once; unrelated recipes stay quiet', () => {
const warnings: string[] = [];
const original = console.warn;
console.warn = (msg: string) => warnings.push(String(msg));
RECIPES.set(caplessRecipe.id, caplessRecipe);
try {
configureOpenAI();
expect(warnings.length).toBe(0);
// #970 regression: google now declares max_batch_tokens → quiet.
configureGoogle();
expect(warnings.length).toBe(0);
configureCapless();
const firstCallCount = warnings.length;
// Reconfigure: the warning should NOT re-fire for the same recipes
// within one process (we already told the operator).
configureGoogle();
configureCapless();
expect(warnings.length).toBe(firstCallCount);
} finally {
console.warn = original;
RECIPES.delete(caplessRecipe.id);
}
// The warning text should match the documented contract.
@@ -415,11 +518,12 @@ describe('startup warning for recipes missing max_batch_tokens', () => {
);
expect(contractMatch.length).toBe(1);
// Voyage declares max_batch_tokens → suppressed. OpenAI is the
// canonical fast-path recipe → also suppressed by id. Both must be
// absent from the warnings.
// Voyage + google declare max_batch_tokens → suppressed. OpenAI is the
// canonical fast-path recipe → also suppressed by id. All must be
// absent from the warnings; only the synthetic capless recipe fires.
expect(warnings.find(w => w.includes('"voyage"'))).toBeUndefined();
expect(warnings.find(w => w.includes('"openai"'))).toBeUndefined();
expect(warnings.find(w => w.includes('"google"'))).toBeDefined();
expect(warnings.find(w => w.includes('"google"'))).toBeUndefined();
expect(warnings.find(w => w.includes('"capless-test"'))).toBeDefined();
});
});
+13 -13
View File
@@ -52,16 +52,7 @@ describe('v0.32 #779: no_batch_cap suppresses the missing-max_batch_tokens warni
}
});
test('configureGateway warns for google only when google embedding is configured', () => {
warnSpy.mockClear();
resetGateway();
configureGateway({ env: {} });
let messages = warnSpy.mock.calls.map(c => String(c[0] ?? ''));
expect(
messages.some(m => m.includes('"google"') && m.includes('without max_batch_tokens')),
'google should not warn while OpenAI default is configured',
).toBe(false);
test('configureGateway does NOT warn for google now that it declares batch caps (#970)', () => {
warnSpy.mockClear();
resetGateway();
configureGateway({
@@ -69,11 +60,20 @@ describe('v0.32 #779: no_batch_cap suppresses the missing-max_batch_tokens warni
embedding_dimensions: 768,
env: { GOOGLE_GENERATIVE_AI_API_KEY: 'fake' },
});
messages = warnSpy.mock.calls.map(c => String(c[0] ?? ''));
const messages = warnSpy.mock.calls.map(c => String(c[0] ?? ''));
expect(
messages.some(m => m.includes('"google"') && m.includes('without max_batch_tokens')),
'google should warn when configured because it has fixed-cap models',
).toBe(true);
'google declares max_batch_tokens/max_batch_count since #970 — no warning',
).toBe(false);
});
test('google recipe declares its derived batch caps (#970)', () => {
const e = getRecipe('google')!.touchpoints.embedding!;
// Count cap is the REAL Gemini limit (batchEmbedContents: 100 inputs);
// the token budget is derived (100 × 2048 per-input tokens), NOT the
// 2048 per-input limit — copying that verbatim would over-split 50×.
expect(e.max_batch_count).toBe(100);
expect(e.max_batch_tokens).toBe(204_800);
});
test('every recipe with empty models[] declares user_provided_models OR has openai-fast-path', () => {
+5
View File
@@ -55,6 +55,11 @@ describe('recipe: dashscope', () => {
expect(r.touchpoints.embedding!.chars_per_token).toBeGreaterThan(0);
});
test('declares max_batch_count: 10 — DashScope rejects larger batches (#1199)', () => {
const r = getRecipe('dashscope')!;
expect(r.touchpoints.embedding!.max_batch_count).toBe(10);
});
test('dimsProviderOptions threads dimensions for text-embedding-v3 (Matryoshka)', async () => {
// Codex finding #1: DashScope text-embedding-v3 is Matryoshka 64-1024.
// Without `dimensions` on the wire, user-selected non-default dims are
+161
View File
@@ -0,0 +1,161 @@
/**
* #1818: embedBatch dispatches its 100-input sub-batches through a bounded
* worker pool (the embed-stale.ts concurrency pattern) instead of a serial
* `for` loop. This file pins:
*
* - output order matches input order regardless of completion order
* (index-addressed results)
* - parallelism actually happens (max in-flight > 1) and stays bounded
* (max in-flight <= configured concurrency)
* - concurrency: 1 restores the serial pre-#1818 dispatch
* - GBRAIN_EMBED_BATCH_CONCURRENCY env is honored when the option is unset
* - onBatchComplete reports a monotonic completed count ending at total
*
* Transport is stubbed via the gateway's __setEmbedTransportForTests seam
* (same pattern as test/ai/adaptive-embed-batch.test.ts). OpenAI recipe =
* fast path (no pre-split), so each embedBatch sub-batch is exactly one
* transport call.
*/
import { afterAll, afterEach, beforeEach, describe, expect, test } from 'bun:test';
import {
configureGateway,
resetGateway,
__setEmbedTransportForTests,
} from '../src/core/ai/gateway.ts';
import { embedBatch } from '../src/core/embedding.ts';
import { withEnv } from './helpers/with-env.ts';
const DIMS = 1536;
function configureOpenAI(): void {
configureGateway({
embedding_model: 'openai:text-embedding-3-large',
embedding_dimensions: DIMS,
env: { OPENAI_API_KEY: 'sk-fake' },
});
}
/**
* Install a transport whose returned embedding encodes the GLOBAL input
* index in dim 0 (texts are `t<N>`), so order can be asserted end-to-end.
* Tracks the max number of concurrently in-flight transport calls.
*/
function installTrackingTransport(delayMs = 5): { maxInFlight: () => number } {
let inFlight = 0;
let maxInFlight = 0;
__setEmbedTransportForTests((async ({ values }: { values: string[] }) => {
inFlight++;
maxInFlight = Math.max(maxInFlight, inFlight);
await new Promise(r => setTimeout(r, delayMs));
inFlight--;
return {
embeddings: values.map(v => {
const idx = Number(v.slice(1));
return Array.from({ length: DIMS }, (_, j) => (j === 0 ? idx : 0.1));
}),
};
}) as any);
return { maxInFlight: () => maxInFlight };
}
const texts = Array.from({ length: 250 }, (_, i) => `t${i}`);
afterAll(() => resetGateway());
describe('embedBatch bounded parallelism (#1818)', () => {
beforeEach(() => {
resetGateway();
configureOpenAI();
});
afterEach(() => {
__setEmbedTransportForTests(null);
});
test('default pool dispatches sub-batches in parallel, order preserved', async () => {
const tracker = installTrackingTransport();
const result = await embedBatch(texts, { onBatchComplete: () => {} });
expect(result).toHaveLength(250);
for (let i = 0; i < 250; i++) {
expect(result[i][0]).toBe(i);
}
// 250 texts → 3 sub-batches; default concurrency 4 → all 3 in flight.
expect(tracker.maxInFlight()).toBeGreaterThan(1);
expect(tracker.maxInFlight()).toBeLessThanOrEqual(4);
});
test('concurrency: 1 keeps the serial dispatch', async () => {
const tracker = installTrackingTransport();
const result = await embedBatch(texts, { concurrency: 1, onBatchComplete: () => {} });
expect(result).toHaveLength(250);
expect(tracker.maxInFlight()).toBe(1);
});
test('GBRAIN_EMBED_BATCH_CONCURRENCY env bounds the pool when option unset', async () => {
const tracker = installTrackingTransport();
await withEnv({ GBRAIN_EMBED_BATCH_CONCURRENCY: '2' }, async () => {
await embedBatch(texts, { onBatchComplete: () => {} });
});
expect(tracker.maxInFlight()).toBeGreaterThan(1);
expect(tracker.maxInFlight()).toBeLessThanOrEqual(2);
});
test('onBatchComplete reports a monotonic count ending at total', async () => {
installTrackingTransport();
const seen: number[] = [];
await embedBatch(texts, {
onBatchComplete: (done, total) => {
expect(total).toBe(250);
seen.push(done);
},
});
expect(seen).toHaveLength(3); // 100 + 100 + 50 sub-batches
for (let i = 1; i < seen.length; i++) {
expect(seen[i]).toBeGreaterThan(seen[i - 1]);
}
expect(seen[seen.length - 1]).toBe(250);
});
test('a failing sub-batch rejects the whole call', async () => {
let call = 0;
__setEmbedTransportForTests((async ({ values }: { values: string[] }) => {
call++;
if (call === 2) throw new Error('boom');
await new Promise(r => setTimeout(r, 2));
return { embeddings: values.map(() => Array.from({ length: DIMS }, () => 0.1)) };
}) as any);
await expect(embedBatch(texts, { onBatchComplete: () => {} })).rejects.toThrow();
});
test('after a failure, surviving workers stop dispatching new slices', async () => {
// 1000 texts → 10 slices, concurrency 2. First call fails immediately;
// without the `failed` flag the second worker would keep draining all
// 10 slices in the background AFTER embedBatch already rejected —
// burning provider spend and firing onBatchComplete post-rejection.
let calls = 0;
const completions: number[] = [];
__setEmbedTransportForTests((async ({ values }: { values: string[] }) => {
calls++;
if (calls === 1) throw new Error('boom');
await new Promise(r => setTimeout(r, 5));
return { embeddings: values.map(() => Array.from({ length: DIMS }, () => 0.1)) };
}) as any);
const many = Array.from({ length: 1000 }, (_, i) => `t${i}`);
await expect(
embedBatch(many, { concurrency: 2, onBatchComplete: d => completions.push(d) }),
).rejects.toThrow('boom');
const callsAtRejection = calls;
await new Promise(r => setTimeout(r, 50)); // would-be background drain window
expect(calls).toBe(callsAtRejection); // no new dispatch after rejection
expect(calls).toBeLessThanOrEqual(2); // only the in-flight sibling ran
expect(completions).toHaveLength(0); // no progress reported after failure
});
test('single small batch without callback stays on the one-call fast path', async () => {
const tracker = installTrackingTransport(1);
const result = await embedBatch(['t0', 't1', 't2']);
expect(result).toHaveLength(3);
expect(result[1][0]).toBe(1);
expect(tracker.maxInFlight()).toBe(1);
});
});
+27 -3
View File
@@ -19,7 +19,7 @@
* overwrites this preload.
*/
import { configureGateway, getEmbeddingDimensions } from '../../src/core/ai/gateway.ts';
import { beforeEach } from 'bun:test';
import { afterEach, beforeEach } from 'bun:test';
const LEGACY_CONFIG = {
embedding_model: 'openai:text-embedding-3-large',
@@ -52,7 +52,7 @@ applyLegacy();
// 2. file-local beforeAll → may overwrite to ZE/1280
// Since beforeAll runs once per file BEFORE the first beforeEach,
// file-local beforeAll wins for that file's tests. ✓
beforeEach(() => {
function applyLegacyIfEmpty() {
try {
// Only re-apply if the gateway was reset (or never configured).
// Tests that explicitly configured a different model in their
@@ -62,4 +62,28 @@ beforeEach(() => {
} catch {
applyLegacy();
}
});
}
beforeEach(applyLegacyIfEmpty);
// PR #3130 shard-order fix: beforeEach alone leaves ONE window open — a file
// whose LAST afterEach calls resetGateway() poisons the NEXT file's
// beforeAll, which runs BEFORE any beforeEach fires. A beforeAll there that
// does engine.initSchema() then sizes the embedding column from the gateway
// DEFAULTS (zembed-1/1280d) instead of the pinned legacy 1536, and every
// 1536-d Float32Array fixture in that file dies with
// "expected 1280 dimensions, not 1536". Which file pair collides is a
// function of shard composition, so adding/removing ANY test file can
// surface it (that is exactly how it bit shard 9).
//
// Preload hooks are registered before any file-local hooks, and bun runs
// after-hooks inside-out (file-local afterEach first, then this one), so
// this repairs the empty slot immediately after the poisoning reset —
// before the next file's beforeAll can observe it.
//
// Known remaining window: a file whose afterAll() resets the gateway (no
// hook runs between its afterAll and the next file's beforeAll). Files
// that reset in afterAll and can precede a schema-creating file should
// re-apply their own config, or the victim file should configureGateway()
// explicitly in its beforeAll.
afterEach(applyLegacyIfEmpty);
+69
View File
@@ -0,0 +1,69 @@
/**
* #1207: `gbrain import` without `--workers` used to hardcode workerCount=1,
* so a large Postgres import paid one serial embedding round-trip per file.
* runImport now routes the default through the shared autoConcurrency policy
* (PGLite → 1, >100 files on Postgres → DEFAULT_PARALLEL_WORKERS), while an
* explicit `--workers N` still wins.
*
* The engine here is a minimal postgres-kind stub with no database_url in
* config — runImport's parallel branch then falls back to serial processing
* (its PR #490 guard) but the WORKER-COUNT DECISION (the thing #1207 fixes)
* is still observable via the "Using N parallel workers" log line. Per-file
* imports fail against the stub engine and are swallowed by runImport's
* per-file catch; that's fine — this test pins the policy, not the import.
*/
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { mkdtempSync, writeFileSync, mkdirSync, rmSync, realpathSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { withEnv } from './helpers/with-env.ts';
import { runImport } from '../src/commands/import.ts';
const fakePostgresEngine = {
kind: 'postgres',
executeRaw: async () => [],
logIngest: async () => {},
setConfig: async () => {},
getConfig: async () => null,
} as any;
let workspace: string;
let brainDir: string;
let logs: string[];
const realLog = console.log;
beforeEach(() => {
workspace = mkdtempSync(join(tmpdir(), 'gbrain-import-workers-home-'));
mkdirSync(join(workspace, '.gbrain'), { recursive: true });
brainDir = realpathSync(mkdtempSync(join(tmpdir(), 'gbrain-import-workers-brain-')));
// 101 files: one past AUTO_CONCURRENCY_FILE_THRESHOLD (100).
for (let i = 0; i < 101; i++) {
writeFileSync(join(brainDir, `page-${i}.md`), `# Page ${i}\n\nbody ${i}\n`);
}
logs = [];
console.log = (msg?: unknown) => logs.push(String(msg));
});
afterEach(() => {
console.log = realLog;
rmSync(workspace, { recursive: true, force: true });
rmSync(brainDir, { recursive: true, force: true });
});
describe('import default worker count (#1207)', () => {
test('no --workers flag → autoConcurrency picks 4 for >100 files on Postgres', async () => {
await withEnv({ GBRAIN_HOME: join(workspace, '.gbrain'), GBRAIN_SOURCE: undefined }, async () => {
await runImport(fakePostgresEngine, [brainDir, '--no-embed'], { sourceId: 'default' });
});
expect(logs.some(l => l.includes('Using 4 parallel workers'))).toBe(true);
});
test('explicit --workers 2 still wins over the auto policy', async () => {
await withEnv({ GBRAIN_HOME: join(workspace, '.gbrain'), GBRAIN_SOURCE: undefined }, async () => {
await runImport(fakePostgresEngine, [brainDir, '--no-embed', '--workers', '2'], { sourceId: 'default' });
});
expect(logs.some(l => l.includes('Using 2 parallel workers'))).toBe(true);
expect(logs.some(l => l.includes('Using 4 parallel workers'))).toBe(false);
});
});
-154
View File
@@ -1,154 +0,0 @@
import { describe, expect, test } from 'bun:test';
import { referenceExclusionSql, REFERENCE_FRONTMATTER_KEY } from '../src/core/reference-flag.ts';
import { applyReferenceFrontmatter } from '../src/commands/reference.ts';
describe('referenceExclusionSql', () => {
test('bare pages (no alias)', () => {
expect(referenceExclusionSql()).toBe(`(frontmatter->>'reference') IS DISTINCT FROM 'true'`);
});
test('aliased', () => {
expect(referenceExclusionSql('p')).toBe(`(p.frontmatter->>'reference') IS DISTINCT FROM 'true'`);
});
test('key constant', () => {
expect(REFERENCE_FRONTMATTER_KEY).toBe('reference');
});
});
describe('applyReferenceFrontmatter', () => {
const page = `---\ntitle: Andy Grove\ntype: person\ntags: []\n---\n\n# Andy Grove\n\nBody.`;
test('adds reference: true to an existing frontmatter block', () => {
const out = applyReferenceFrontmatter(page, true);
expect(out).toContain('reference: true');
expect(out).toContain('type: person'); // other keys preserved
expect(out).toContain('# Andy Grove'); // body preserved
});
test('is idempotent — does not duplicate the key', () => {
const once = applyReferenceFrontmatter(page, true);
const twice = applyReferenceFrontmatter(once, true);
expect(twice).toBe(once);
expect(twice.match(/reference: true/g)).toHaveLength(1);
});
test('replaces a stale reference: false with true', () => {
const off = `---\ntype: person\nreference: false\n---\n\nBody.`;
const out = applyReferenceFrontmatter(off, true);
expect(out).toContain('reference: true');
expect(out).not.toContain('reference: false');
});
test('--unset removes the key', () => {
const on = applyReferenceFrontmatter(page, true);
const off = applyReferenceFrontmatter(on, false);
expect(off).not.toContain('reference:');
expect(off).toContain('type: person');
});
test('--unset on a page without the key is a no-op', () => {
expect(applyReferenceFrontmatter(page, false)).toBe(page);
});
test('setting on a frontmatter-less page prepends a block', () => {
const raw = '# Just a heading\n\nNo frontmatter here.';
const out = applyReferenceFrontmatter(raw, true);
expect(out.startsWith('---\nreference: true\n---\n')).toBe(true);
expect(out).toContain('# Just a heading');
});
test('preserves body containing YAML-special chars', () => {
const tricky = `---\ntype: person\n---\n\nText with $& and $1 literals.`;
const out = applyReferenceFrontmatter(tricky, true);
expect(out).toContain('Text with $& and $1 literals.');
expect(out).toContain('reference: true');
});
});
// ── e2e: getHealth exemption + (source_id, slug)-scoped JSONB write ─────────
import { afterAll, beforeAll } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { runReference } from '../src/commands/reference.ts';
import { withEnv } from './helpers/with-env.ts';
describe('reference flag e2e (PGLite)', () => {
let engine: PGLiteEngine;
let brainDir: string;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({ database_url: '' });
await engine.initSchema();
brainDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gbrain-ref-'));
fs.mkdirSync(path.join(brainDir, 'people'), { recursive: true });
fs.writeFileSync(
path.join(brainDir, 'people/andy-grove.md'),
'---\ntitle: Andy Grove\ntype: person\n---\n\n# Andy Grove\n',
'utf8',
);
await engine.executeRaw(
`INSERT INTO sources (id, name, local_path, config, created_at)
VALUES ('src-a', 'a', $1, '{}'::jsonb, now()), ('src-b', 'b', NULL, '{}'::jsonb, now())`,
[brainDir],
);
// Same slug in BOTH sources — the write must only touch src-a.
await engine.putPage('people/andy-grove', {
type: 'person', title: 'Andy Grove', compiled_truth: 'canon figure',
}, { sourceId: 'src-a' });
await engine.putPage('people/andy-grove', {
type: 'person', title: 'Andy Grove', compiled_truth: 'other-source twin',
}, { sourceId: 'src-b' });
// A normal contact WITH a timeline entry, so coverage has a live numerator.
const contact = await engine.putPage('people/alice-example', {
type: 'person', title: 'Alice Example', compiled_truth: 'real contact',
}, { sourceId: 'src-a' });
await engine.executeRaw(
`INSERT INTO timeline_entries (page_id, date, summary) VALUES ($1, '2026-01-01', 'met')`,
[contact.id],
);
});
afterAll(async () => {
await engine.disconnect();
fs.rmSync(brainDir, { recursive: true, force: true });
});
test('runReference scopes the JSONB write to the resolved (source_id, slug)', async () => {
await withEnv({ GBRAIN_SOURCE: undefined }, () =>
runReference(engine, ['people/andy-grove', '--brain', brainDir]));
const rows = await engine.executeRaw<{ source_id: string; ref: string | null }>(
`SELECT source_id, frontmatter->>'reference' AS ref FROM pages WHERE slug = 'people/andy-grove' ORDER BY source_id`,
);
expect(rows).toEqual([
{ source_id: 'src-a', ref: 'true' },
{ source_id: 'src-b', ref: null }, // twin in the other source untouched
]);
// Durable half: frontmatter on disk got the flag too.
expect(fs.readFileSync(path.join(brainDir, 'people/andy-grove.md'), 'utf8'))
.toContain('reference: true');
});
test('getHealth exempts reference pages from timeline/link coverage', async () => {
const health = await engine.getHealth();
// 3 person pages; the 2 reference-less twins would drag coverage to 1/3.
// With the src-a twin marked reference, denominator = 2 (alice + src-b twin).
expect(health.timeline_coverage).toBeCloseTo(0.5);
});
test('unset restores the page to a normal counted entity', async () => {
await withEnv({ GBRAIN_SOURCE: undefined }, () =>
runReference(engine, ['people/andy-grove', '--unset', '--brain', brainDir]));
const rows = await engine.executeRaw<{ ref: string | null }>(
`SELECT frontmatter->>'reference' AS ref FROM pages WHERE slug = 'people/andy-grove' AND source_id = 'src-a'`,
);
expect(rows[0].ref).toBeNull();
const health = await engine.getHealth();
expect(health.timeline_coverage).toBeCloseTo(1 / 3);
});
});