Files
gbrain/src/core/eval-capture.ts
T
1d5f69fe7a v0.36.3.0 feat: dynamic embedding column selection for search (#1164)
* feat: migration v68 — eval_candidates.embedding_column

Schema migration ALTERs eval_candidates to add a nullable
embedding_column TEXT column. Per-row capture metadata so
`gbrain eval replay` reproduces the same column the
capture ran against (D16 / CDX-10). NULL-tolerant: pre-v0.36
rows fall back to current default.

Renumbered v67→v68 because master claimed v67 for
facts_typed_claim_columns during this branch's lifetime.

PGLite parity via sqlFor.pglite — same ALTER IF NOT EXISTS.

* feat: dynamic embedding column — core (resolver, types, gateway, engines)

The read-path foundation for routing search through any
populated embedding column, not just OpenAI 1536.

src/core/search/embedding-column.ts (new) is the canonical
seam. Single source of truth for column → provider/dim/type
lookup. Validates registry keys via regex
(/^[a-z_][a-z0-9_]*$/), uses Object.create(null) +
Object.hasOwn so 'constructor' and other inherited names
can't masquerade as registered columns. Identifier-quoting
on SQL interpolation as defense in depth.

src/core/types.ts widens SearchOpts.embeddingColumn to
accept ResolvedColumn descriptors at the engine boundary;
adds EmbeddingColumnConfig + ResolvedColumn exports.

src/core/config.ts merges embedding_columns +
search_embedding_column from the DB plane via
loadConfigWithEngine, mirroring the existing
embedding_multimodal_model pattern. Handles the no-file
case so env-only Postgres installs see DB-plane overrides
(codex /ship #3).

src/core/ai/gateway.ts: embedQuery(text, opts) +
embed(texts, opts) accept embeddingModel + dimensions
overrides. isAvailable(touchpoint, modelOverride?) so
hybrid asks 'is the active column's provider reachable?'
not 'is the global default reachable?' (CDX-4 / D10).

Engines: searchVector accepts ResolvedColumn descriptors via
normalizeEngineColumn; engine code is config-free and
unit-testable. getEmbeddingsByChunkIds(ids, column?) so
cosineReScore hydrates from the active column instead of
always 'embedding' (CDX-3 / D9). Identifier-quoting belt at
the SQL boundary.

src/core/eval-capture.ts threads embedding_column from
hybridSearch meta into the persisted capture row.

* feat: dynamic embedding column — integration (hybrid, ops, doctor)

Wires the resolver into hybridSearch, the query op, doctor,
and the config command.

src/core/search/hybrid.ts: resolves the column once at the
boundary, threads the descriptor into engine calls, routes
embedQuery through the resolved column's provider/dims, and
calls isCacheSafe (not isDefaultColumn) for cache skip so
user overrides of the 'embedding' builtin can't leak across
vector spaces (CDX-4). cosineReScore now hydrates from the
active column.

src/core/search/mode.ts: KNOBS_HASH_VERSION 2→3, append-only
new fields col= and prov= alongside floor_ratio. Cache rows
from different columns or providers now sit in different
keyspaces — cross-column contamination impossible.

src/core/operations.ts: query op accepts embedding_column
param for per-call A/B benchmarking. search op (keyword-only)
deliberately does NOT (CDX-9 / D15) — would be silent UX.

src/commands/doctor.ts: new embedding_column_registry
check. Batch format_type probe (D13) catches dim drift
that information_schema.columns.udt_name can't.
Batch pg_indexes probe (D5) warns on missing HNSW. Coverage
% on active column, gates at <90% (D14), short-circuits on
empty brains (codex /ship #5).

src/commands/config.ts: validates embedding_columns JSON
shape at set time, runs the coverage gate when setting
search_embedding_column, uses Object.hasOwn for the
registry lookup.

src/commands/eval-replay.ts: replay re-runs queries against
the captured embedding_column so post-flip-config replays
don't surface as false-positive regressions.

* test: dynamic embedding column — unit + e2e coverage

50 unit cases for the resolver (resolution chain, registry
merge, validation, prototype pollution, descriptor
passthrough, isCacheSafe, normalizeEngineColumn).

8 gateway override cases — embeddingModel + dimensions
flow into providerOptions, isAvailable(touchpoint, override)
routes to the right recipe, unknown models throw clean.

4 cosineReScore + 6 ops + 5 knobs-hash + 7 mode + 9 PGLite
E2E + 7 Postgres E2E + 5 eval-replay column metadata.

Postgres E2E (gated on DATABASE_URL) covers halfvec(2560)
end-to-end on real pgvector, EXPLAIN-visible HNSW index
on the alternate column, format_type-based dim drift catch,
and the <90% coverage gate.

Pins every codex /ship fix: prototype-pollution rejection
('constructor' as column name), descriptor passthrough
validation (rejects SQL-shaped strings in dimensions),
isCacheSafe semantics (space-based, not name-based).

Total: 141 new + extended cases, all green.

* chore: bump version and changelog (v0.36.3.0)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: sync to v0.36.3.0

Add CLAUDE.md key-files entry for src/core/search/embedding-column.ts.
Annotate hybrid.ts, gateway.ts, doctor.ts, and migrate.ts entries with
v0.36.3.0 wave changes (ResolvedColumn threading, embedQuery model
override, embedding_column_registry check, migration v68). Document
knobs_hash v=2 → v=3 bump under the Search Mode section.

Regenerate llms-full.txt from the updated CLAUDE.md so the auto-checked
bundle matches source (build-llms.test.ts CI guard).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ci): two CI failures from v0.36.3.0

1. test/loadConfig-merge.test.ts: update the 'returns null when base
   config is null' contract test. Pre-v0.36 the function returned null
   for null base; the codex /ship #3 fix changed that to synthesize a
   minimal `{ engine: 'postgres' }` so env-only installs see DB-plane
   overrides. Test now pins the new contract + adds a round-trip case
   asserting the merge actually surfaces `embedding_columns` /
   `search_embedding_column` set via gbrain config set on a null base.

2. test/schema-bootstrap-coverage.test.ts was failing because
   eval_candidates.embedding_column (added by migration v68) wasn't
   covered by applyForwardReferenceBootstrap. Fix: add the column to
   PGLITE_SCHEMA_SQL's eval_candidates CREATE TABLE definition (and
   src/schema.sql for parity) so fresh installs get it natively. The
   coverage test's third tier (schemaCreateTableCols) now finds it.
   Regenerated schema-embedded.ts via bun run build:schema.

Schema-blob path is cleaner than COLUMN_EXEMPTIONS — fresh installs
skip the migration entirely; upgrade installs still run v68.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:26:12 -07:00

213 lines
8.3 KiB
TypeScript

/**
* Op-layer capture wrapper (v0.21.0).
*
* Catches MCP + CLI + subagent tool-bridge traffic from a single site —
* the `query` and `search` op handlers in src/core/operations.ts decorate
* themselves with `captureEvalCandidate` before they run. Post-codex O1+O2
* design: the MCP server used to be the capture site, but subagent tool
* calls dispatch straight to op.handler via brain-allowlist.ts and would
* have been invisible. Moving the hook to the op layer covers every caller.
*
* Best-effort, not await'd by the caller. Every failure is routed through
* `engine.logEvalCaptureFailure(reason)` so `gbrain doctor` can see drops
* cross-process (in-process counters would be invisible from doctor's
* separate process).
*
* Data-flow (happy path):
*
* op.handler() ──▶ {results, meta}
* │
* (fire-and-forget from caller)
* ▼
* outer try / inner .catch
* │
* ▼
* scrubPii(query)
* │
* ▼
* buildEvalCandidateInput(...)
* │
* ▼
* engine.logEvalCandidate(input)
*
* Every error path — scrub throw, build throw, INSERT reject — lands at
* `logEvalCaptureFailure(reason)` with the right reason tag.
*/
import type { BrainEngine } from './engine.ts';
import type {
EvalCandidateInput,
EvalCaptureFailureReason,
HybridSearchMeta,
SearchResult,
} from './types.ts';
import type { GBrainConfig } from './config.ts';
import { scrubPii } from './eval-capture-scrub.ts';
// HybridSearchMeta is canonical in src/core/types.ts and exported via the
// public `gbrain/types` subpath. Surfaced from hybridSearch via the
// optional onMeta callback in HybridSearchOpts (Lane 1C).
export type { HybridSearchMeta };
/** Context needed to build a capture row. Bundled so op handlers don't thread 8 args. */
export interface CaptureContext {
/** 'query' or 'search'; captured only for these two ops. */
tool_name: 'query' | 'search';
/** Pre-scrub query text. scrubPii runs INSIDE buildEvalCandidateInput when scrub_pii !== false. */
query: string;
/** Result set from the op handler. */
results: SearchResult[];
/** Side-channel metadata from hybridSearch. For 'search' ops, pass vector_enabled:false, others null/false. */
meta: HybridSearchMeta;
/** How long the underlying op took, in milliseconds. */
latency_ms: number;
/** OperationContext.remote — true for MCP callers, false for local CLI. */
remote: boolean;
/** The `expand` flag as requested by the caller (query-only; null for search). */
expand_enabled: boolean | null;
/** The `detail` flag as requested (query-only). */
detail: 'low' | 'medium' | 'high' | null;
/** OperationContext.jobId if present. */
job_id: number | null;
/** OperationContext.subagentId if present. */
subagent_id: number | null;
}
/**
* Build the insert row for `eval_candidates` from a capture context.
*
* Runs the PII scrubber on `query` unless `scrub_pii === false`. Pure
* function: throws only if the scrubber throws (caller wraps in try/catch).
*
* Exported for testing and for CLI backfill paths; the hot-path caller is
* `captureEvalCandidate` below.
*/
export function buildEvalCandidateInput(
ctx: CaptureContext,
opts: { scrub_pii?: boolean } = {},
): EvalCandidateInput {
const shouldScrub = opts.scrub_pii !== false;
const query = shouldScrub ? scrubPii(ctx.query) : ctx.query;
// Deduplicate + preserve order for slug + source_id extraction.
// Both arrays are small (hybridSearch clamps at 100 results) so the
// O(n) Set-based dedup is fine.
const slugsSet = new Set<string>();
const sourceSet = new Set<string>();
const chunkIds: number[] = [];
for (const r of ctx.results) {
slugsSet.add(r.slug);
if (r.source_id) sourceSet.add(r.source_id);
chunkIds.push(r.chunk_id);
}
return {
tool_name: ctx.tool_name,
query,
retrieved_slugs: [...slugsSet],
retrieved_chunk_ids: chunkIds,
source_ids: [...sourceSet],
expand_enabled: ctx.expand_enabled,
detail: ctx.detail,
detail_resolved: ctx.meta.detail_resolved,
vector_enabled: ctx.meta.vector_enabled,
expansion_applied: ctx.meta.expansion_applied,
latency_ms: ctx.latency_ms,
remote: ctx.remote,
job_id: ctx.job_id,
subagent_id: ctx.subagent_id,
// v0.36 (D16 / CDX-10): persist the column that ran. Null for
// keyword-only `search` (meta.embedding_column omitted), preserving
// back-compat with rows captured before the column tracking landed.
embedding_column: ctx.meta.embedding_column ?? null,
};
}
/**
* Map a caught error to an `EvalCaptureFailureReason` so doctor can
* group failures by cause (DB down vs RLS reject vs CHECK violation).
*
* Narrow by Postgres SQLSTATE where we can (postgres-js + PGLite both
* surface `.code`), fall back to 'other'.
*/
export function classifyCaptureFailure(err: unknown): EvalCaptureFailureReason {
if (err && typeof err === 'object') {
const code = (err as { code?: string }).code;
if (code === '23514') return 'check_violation'; // CHECK constraint violation
if (code === '42501') return 'rls_reject'; // insufficient_privilege
if (code === '42P01') return 'db_down'; // undefined_table (pre-v25)
if (code === '53300' || code === '08006' || code === '08003') return 'db_down';
const name = (err as { name?: string }).name;
if (name === 'RegExpMatchError' || name === 'SyntaxError') {
return 'scrubber_exception';
}
}
return 'other';
}
/**
* Fire-and-forget capture side-effect. Never throws: every failure is
* swallowed, classified, and routed through `engine.logEvalCaptureFailure`
* (itself wrapped — failure-of-failure is logged to stderr and dropped).
*
* Callers invoke as `void captureEvalCandidate(...)` — we explicitly do
* NOT await so the op response latency is unaffected.
*/
export async function captureEvalCandidate(
engine: BrainEngine,
ctx: CaptureContext,
opts: { scrub_pii?: boolean } = {},
): Promise<void> {
try {
const input = buildEvalCandidateInput(ctx, opts);
await engine.logEvalCandidate(input);
} catch (err) {
const reason = classifyCaptureFailure(err);
try {
await engine.logEvalCaptureFailure(reason);
} catch (failureErr) {
// Failure-of-failure: last-resort stderr. Doctor can't see this
// row, but we've exhausted the persistent path.
// eslint-disable-next-line no-console
console.warn('[eval-capture] secondary failure logging also failed:', failureErr);
}
}
}
/**
* Check whether capture is enabled for this process.
*
* Resolution order:
* 1. `config.eval.capture === true` → on (explicit user opt-in wins)
* 2. `config.eval.capture === false` → off (explicit user opt-out wins)
* 3. `process.env.GBRAIN_CONTRIBUTOR_MODE === '1'` → on (contributor opt-in)
* 4. otherwise → off (default-off, privacy-positive for end users)
*
* The default flipped in v0.25.0 from "on for everyone" to "off unless you
* opt in." Capturing every query a real user runs without their consent is
* a footgun even with PII scrubbing; tying capture to CONTRIBUTOR_MODE makes
* the developer-skill nature of the feature explicit. Production users get
* a quiet brain; contributors get the BrainBench-Real replay loop with one
* shell rc line. See docs/eval-bench.md and CONTRIBUTING.md.
*
* Takes the already-loaded config so callers control the loadConfig()
* lifecycle (MCP server loads once at boot, CLI commands load per-invocation).
*/
export function isEvalCaptureEnabled(config: GBrainConfig | null | undefined): boolean {
if (config?.eval?.capture === true) return true;
if (config?.eval?.capture === false) return false;
return process.env.GBRAIN_CONTRIBUTOR_MODE === '1';
}
/**
* PII scrubbing enabled? Defaults to true; explicit `false` opts out.
*
* Independent of `isEvalCaptureEnabled` — scrubbing only matters when capture
* is actually running, but the gate stays separate so CONTRIBUTOR_MODE
* doesn't accidentally turn off scrubbing on a brain that happens to also
* have explicit `capture: true`.
*/
export function isEvalScrubEnabled(config: GBrainConfig | null | undefined): boolean {
return config?.eval?.scrub_pii !== false;
}