Files
gbrain/src/core/search/mode.ts
T
e60b60244f v0.36.6.0 feat: cross-modal search wave (text↔image + unified column + LLM intent) (#1165)
* feat(cross-modal/0): batched multimodal + query helpers + SSRF helper

Commit 0 of the cross-modal search wave. Foundation for Phase 1-3:

- embedMultimodal accepts MultimodalInput text variant + EmbedMultimodalOpts
  with inputType: 'document' | 'query' (D22-2). Default unchanged so
  importImageFile keeps document-side embedding.
- embedQueryMultimodal(text) + embedQueryMultimodalImage(input) wrappers
  for hybridSearch + searchByImage query paths.
- embedMultimodalSafe binary-search retry on transient batch failure +
  failed_indices surfacing. Phase 3 reindex uses this so a single bad
  chunk doesn't discard the 31 in-flight embeddings around it.
- Voyage path: text + image inputs in one batch via content arrays.
- openai-compat path: text + image inputs in one request per input.
- src/core/ssrf-validate.ts (D19): DNS-resolve-and-fetch-by-IP defense
  for redirect chains. Closes the DNS-rebinding gap that url-safety.ts'
  static check leaves open. Uses node:dns/promises with {all: true,
  family: 0} to inspect every A and AAAA record before connecting.
  fetchWithSSRFGuard helper validates per-redirect-hop and limits chain
  depth (default 3).
- Re-exports from src/core/embedding.ts public seam.

Tests:
- test/embed-multimodal-batching.test.ts (13 cases): text variant, query
  inputType discipline, mixed text+image batches, embedQueryMultimodal,
  embedQueryMultimodalImage, embedMultimodalSafe happy/empty/all-fail/
  mid-batch-recovery/permanent-misconfig.
- test/ssrf-validate.test.ts (20 cases): static rejections via
  isInternalUrl, scheme + credentials rejection, DNS rebinding defense
  (single-record + multi-record), public happy path, IPv6 literals,
  malformed URLs.

No regression in existing voyage-multimodal.test.ts or
openai-compat-multimodal.test.ts (33 cases all pass).

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

* feat(cross-modal/1): Phase 1 text→image routing + knobsHash + RRF + backfill

Phase 1 of the cross-modal search wave. Wires the existing 1024d Voyage
multimodal embedding space (already populated for image chunks via
importImageFile) into the user-facing query path. Text queries that match
cross-modal intent regex route through Voyage multimodal-3 instead of the
text embedding model, then search content_chunks.embedding_image.

- query-intent.ts: new `suggestedModality: 'text' | 'image' | 'both'`
  axis on `QuerySuggestions`. Module-scope CROSS_MODAL_PATTERNS regex
  array (D15 — compiled once at module load). Conservative on purpose;
  LLM intent escalation (Commit 4) catches genuinely ambiguous phrasings.
- query-intent.ts: new `isAmbiguousModalityQuery(query)` pure heuristic
  for Commit 4's escalation gate. Returns true ONLY when regex misses
  AND a visual noun + reference marker both fire.
- types.ts: `SearchOpts.crossModal: 'text' | 'image' | 'both' | 'auto'`
  + `SearchResult.modality: 'text' | 'image'` for downstream renderers.
- mode.ts: 7 new knobs in ModeBundle (D2): cross_modal_both_text_weight,
  cross_modal_both_image_weight, image_query_text_refinement_weight,
  image_query_image_refinement_weight, unified_multimodal,
  unified_multimodal_only, cross_modal_llm_intent. All three mode
  bundles default to the same values (cross-modal is opt-in).
- mode.ts: D2 cache-key fix — KNOBS_HASH_VERSION bumped 2→3, all 7 new
  knobs participate in knobsHash so a text-mode cache hit can't be
  served to an image-mode caller.
- mode.ts: D3 registry — all 7 keys land in SEARCH_MODE_CONFIG_KEYS so
  `gbrain search modes` / `stats` / `tune` see them.
- hybrid.ts: routing branch at the embed step. Resolves effective
  modality from (per-call opts → suggestions → 'text'). Image route:
  embedQueryMultimodal + searchVector(embedding_image), skip expansion
  + keyword (D9 mode-bundle override). Both route: parallel text + image
  vector searches merged via weighted RRF (D6) with cross_modal_both_*
  weights. Fail-open: multimodal misconfigured → structured warn + text
  fallback. 'auto' literal normalized to undefined (D22-1).
- operations.ts: thread `cross_modal` param through `query` op.
- backfill-registry.ts: new `modality` backfill kind. SQL filter requires
  `chunk_source='image_asset'` (D22-7 defensive guard). Idempotent.
- doctor.ts: `cross_modal_modality_backfill` check surfaces unflagged
  image-asset chunks with paste-ready `gbrain backfill modality` hint.

Tests:
- cross-modal-phase1.test.ts (45 cases): regex classification (positive
  + negative + plural-safe), isAmbiguousModalityQuery, D3 registry, D2
  knobsHash diffs across all 7 new knobs, MODE_BUNDLES defaults,
  resolveSearchMode precedence chain.
- cross-modal-hybrid-integration.test.ts (7 cases): PGLite + stubbed
  gateway. Verifies image-modality calls Voyage and not OpenAI, text
  calls OpenAI and not Voyage, 'auto' literal normalizes, 'both' mode
  hits both endpoints, fail-open routes to text on multimodal misconfig.
- search-mode.test.ts: updated MODE_BUNDLES + KNOBS_HASH_VERSION
  assertions (148 cross-suite tests still pass; no regression).

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

* feat(cross-modal/2): Phase 2 image-as-query + D18 path ban + D23-#6 spend cap

Phase 2 of the cross-modal search wave. Adds the `search_by_image` MCP op,
the SSRF-defended image loader, and the daily per-OAuth-client spend cap
on paid Voyage multimodal calls. D17 honest framing applied: Phase 2 ships
image→similar-images + image-OCR-text retrieval. True image→full-text-
knowledge requires Phase 3's unified column.

- src/core/search/image-loader.ts: loadImageInput accepts local path,
  data: URI, or http(s):// URL. Magic-byte sniff for PNG/JPEG/WebP (no
  other formats). Hard size cap (10MB local default, 2MB remote default).
  http(s) path uses fetchWithSSRFGuard from Commit 0: every redirect hop
  re-resolved via DNS lookup + every record checked against the internal
  IP deny list. Max 3 redirect hops. 5s total fetch timeout. Pre-flight
  Content-Length check + post-fetch size guard for lying servers.
- src/core/search/by-image.ts: searchByImage runs the image branch
  always; D13 hybrid intersect runs a parallel text branch when
  `query` is provided, merged via weighted RRF. Phase 3 will widen
  the column routing to embedding_multimodal once that lands.
- src/core/operations.ts: new search_by_image op (scope: read, NOT
  localOnly). D18 P0 — when ctx.remote === true AND image_path is set,
  rejects with permission_denied at handler entry (validateParams would
  catch it again at dispatch). D5 source-id thread via sourceScopeOpts.
  D12 per-param length cap enforced via remote-vs-local maxBytes config
  read at handler entry. D23-#6 pre-flight checkBudget + post-call
  recordSpend (best-effort; failures don't block response).
- src/core/spend-log.ts: BudgetExceededError + checkBudget + recordSpend
  + getTodaySpendCents. UTC day-aligned aggregation so the cap rolls
  over deterministically. Local CLI callers (no clientId) bypass the
  gate entirely. Pre-v0.36 brains without the mcp_spend_log table fail
  open to spend=0; the migration brings the table in on first start.
- src/core/migrate.ts: new migration v67 mcp_spend_log table + indexes
  for the (client_id, day) and (token_name, day) hot reads. PGLite
  parity via sqlFor.pglite.
- src/core/search/hybrid.ts: RRF_K constant exported so by-image.ts can
  share the same effective-K math as the main hybrid path.

Tests:
- cross-modal-phase2.test.ts (15 cases): magic-byte sniffing (PNG +
  JPEG + WebP positive, GIF rejection), oversized rejection (default +
  custom cap), data: URI happy path + malformed + decoded-non-image
  + oversized, invalid input shapes (empty + ftp), SSRF defense via
  DNS rebinding stub.
- search-by-image-op.test.ts (7 cases): D18 remote image_path
  rejection + local CLI accepts; input validation (missing all three /
  multiple together); D23-#6 budget block-at-cap + allow-under-cap +
  local-CLI-bypass; migration v67 mcp_spend_log table applied cleanly.

All 166 tests across the cross-modal suite pass; no regression in
existing voyage-multimodal / openai-compat-multimodal / search-mode suites.

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

* feat(cross-modal/3): Phase 3 unified column + reindex + D8 fail-open + D23-#2

Phase 3 of the cross-modal search wave. Adds the unified multimodal column
on content_chunks + the `gbrain reindex --multimodal` sweep + the
`search.unified_multimodal` routing flag with D8 source-aware coverage
guard + fail-open behavior. D17 honest framing: this is the phase that
unlocks true image→full-text-knowledge — Phase 2's searchByImage
transparently upgrades to the richer retrieval once the unified column
has coverage.

D10 reindex-core extraction filed as a follow-up TODO. The existing
markdown reindex walks pages and re-imports via importFromFile; this
walks content_chunks and re-embeds via the gateway. Patterns rhyme but
cores diverge enough that extraction balloons the diff. Both commands
stand alone with their own checkpoint + cost-prompt logic.

- migrate.ts v68 (embedding_multimodal_column): column-only ALTER on
  content_chunks. HNSW partial index deferred to post-reindex build
  (D20: pgvector docs recommend post-load build for HNSW). Both engines.
- types.ts SearchOpts.embeddingColumn type widened to include
  'embedding_multimodal'.
- postgres-engine.ts + pglite-engine.ts searchVector: route to
  embedding_multimodal column when opts.embeddingColumn set. NO modality
  filter (unified column carries both text + image content).
- hybrid.ts unified routing branch: when search.unified_multimodal=true,
  bypasses dual-column branching and runs embedQueryMultimodal +
  searchVector(embedding_multimodal). D8 fail-open: zero rows + not
  strict-mode → falls through to dual-column text path with structured
  warning. search.unified_multimodal_only=true bypasses the fallback.
- src/commands/reindex-multimodal.ts: `gbrain reindex --multimodal`.
  D7 lock via tryAcquireDbLock('gbrain-reindex-multimodal'); 6h TTL.
  Cost prompt + 10s Ctrl-C grace window in TTY; auto-proceeds non-TTY.
  GBRAIN_NO_REEMBED=1 bypass. Checkpoint at
  ~/.gbrain/reindex-multimodal-checkpoint.json for resume. D23-#2
  auto-flip prompt at coverage=100% completion.
- cli.ts: `gbrain reindex --multimodal` dispatch with --limit, --dry-run,
  --cost-estimate, --no-embed, --yes, --json flags.
- doctor.ts: unified_multimodal_coverage check (D21 source-aware) +
  reports per-source % when search.unified_multimodal is on. Warns at
  <95% lowest source; fails when unified_multimodal_only=true AND
  lowest source <99%. Falls open to OK when column not yet present.

Tests:
- unified-multimodal.test.ts (8 cases): schema migration v68 applies,
  reindex --dry-run + --cost-estimate + GBRAIN_NO_REEMBED bypass +
  zero-pending fast-path, hybridSearch unified routing forces voyage
  endpoint, D8 fail-open routes to text on empty unified, D8 strict
  blocks text fallback.

All 211 tests across the cross-modal + related suite pass; no
regression in voyage-multimodal / openai-compat-multimodal / search-mode
/ intent / search base suites.

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

* feat(cross-modal/4): LLM intent escalation for ambiguous modality

Commit 4 of the cross-modal search wave (opt-in default off).

When `search.cross_modal.llm_intent` is true AND the regex classifier
returned 'text' AND `isAmbiguousModalityQuery(query)` fires, hybridSearch
awaits a Haiku tie-break via gateway.chat() before routing. The
ambiguous-modality gate (introduced in Commit 1) ensures the LLM call
only fires on the narrow band where regex misses but a visual noun +
reference marker both fire — roughly <1% of queries with the flag on.

- src/core/search/llm-intent.ts: new module. `classifyModalityWithLLM`
  routes through gateway.chat() with a fixed system prompt ("Output
  exactly one word: text, image, or both"). 1s timeout via AbortController.
  `parseModality` is a pure exported helper that tolerates trailing
  punctuation + casing. Fail-open on every error path (gateway
  unavailable, timeout, parse failure, unrecognized output).
- src/core/search/hybrid.ts: escalation branch slots BEFORE the unified
  routing branch. Gated by: no explicit per-call crossModal opt, regex
  result == 'text', config flag on, ambiguity heuristic fires. Fail-open
  to regex result on any error from the LLM tie-break.

Tests:
- llm-intent-escalation.test.ts (14 cases): parseModality tolerance
  matrix (text / image / both / trailing punct / whitespace /
  unrecognized / empty), classifyModalityWithLLM happy paths for all 3
  outputs, fail-open on throw / unrecognized output / gateway-not-
  configured, explicit-fallback-honored.
- llm-intent-hybrid-integration.test.ts (6 cases): hybridSearch
  escalation gate fires ONLY when flag-on + ambiguous; off when flag-off,
  unambiguous, regex-confident, or explicit per-call opt set; fail-open
  on LLM throw.

All 231 tests across the cross-modal + related suite pass; no
regression in voyage-multimodal / openai-compat-multimodal /
search-mode / intent / search base suites.

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

* fix(cross-modal/3): verify-gate fixes for full test suite

Three small fixes to pass the full unit + E2E sweep after the cross-modal
wave commits land.

- migrate.ts v67: drop date_trunc('day', created_at) from
  mcp_spend_log indexes. TIMESTAMPTZ truncation depends on session
  timezone and isn't IMMUTABLE, so Postgres rejects the function in
  the index expression with SQLSTATE 42P17. BTREE on
  (client_id, created_at) covers the per-day rollup query via range
  scan on created_at — same performance, no IMMUTABLE constraint.
- pglite-schema.ts + src/schema.sql: shorten the embedding_multimodal
  column comment. The longer version contained a comma inside a SQL
  line comment ("...search.unified_multimodal=true, all queries..."),
  which broke parseBaseTableColumns in test/schema-bootstrap-coverage
  (the parser splits on commas at depth-0 before stripping comments,
  so the comma inside the comment shortened the column-definition part
  and an "all" token from "all queries" got picked up as the next
  column name — silently hiding embedding_multimodal from coverage).
- schema-embedded.ts: regenerated via `bun run build:schema`.
- test/e2e/v030_1-integration-pglite.test.ts: listBackfills assertion
  extended to include the new `modality` entry registered in
  src/core/backfill-registry.ts as part of Commit 1.
- test/search/knobs-hash-reranker.test.ts: KNOBS_HASH_VERSION assertion
  updated from 2→3 to match the cross-modal-wave hash-key extension
  (D2 cache contamination fix). Same shape as the prior
  v0.32→v0.35 bump.
- test/unified-multimodal.test.ts: migrated process.env mutation to
  withEnv() helper to satisfy the scripts/check-test-isolation R1
  rule.

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

* docs(cross-modal): VERSION + CHANGELOG + CLAUDE.md + spec doc + llms regen

Final docs commit for the cross-modal wave (v0.36.0.0).

- VERSION + package.json: bump 0.35.5.1 → 0.36.0.0
- CHANGELOG.md: full Garry-voice release entry with five-commit breakdown,
  the-numbers-that-matter table, what-this-means-for-you, and the
  required to-take-advantage-of-v0.36.0.0 block
- docs/issues/cross-modal-search.md: cherry-picked from PR #1127 head
  (164 lines, the original spec doc preserved as historical reference
  for Phase 2 + 3 background)
- CLAUDE.md: Key Files entries for src/core/ssrf-validate.ts,
  src/core/search/image-loader.ts, src/core/search/by-image.ts,
  src/core/search/llm-intent.ts, src/core/spend-log.ts,
  src/commands/reindex-multimodal.ts, plus extension annotations on
  src/core/search/query-intent.ts, src/core/search/mode.ts,
  src/core/search/hybrid.ts, src/core/backfill-registry.ts,
  src/core/migrate.ts (v67 + v68)
- llms-full.txt + llms.txt: regenerated via `bun run build:llms`

`bun run verify` clean (privacy + proposal-pii + test-names + jsonb +
source-id-projection + progress + test-isolation + wasm + admin-build +
admin-scope-drift + cli-exec + system-of-record + eval-glossary +
typecheck). `bun test test/build-llms.test.ts` clean (7/7).

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

* fix(cross-modal): renumber migrations 67→69 + 68→70 post-master-merge

Master shipped its own v67 (`facts_typed_claim_columns`) during the
cross-modal wave's review cycle. The merge picked up both side's v67
entries, breaking the migration-distinct-versions test. Renumbering
moves cross-modal's table + column ALTER off the collision:

- v67 mcp_spend_log → v69 mcp_spend_log
- v68 embedding_multimodal_column → v70 embedding_multimodal_column

References updated in CHANGELOG, CLAUDE.md, pglite-schema.ts, schema.sql.
schema-embedded.ts regenerated. llms-full.txt regenerated.

7006 unit tests pass, 0 fail. No test code touched — just version
renumbering plus comment refs.

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

* chore: bump version 0.36.0.0 → 0.36.4.0

Bumping to v0.36.4.0 to land in the queue slot the user requested.
No behavior change; pure version bump across VERSION, package.json,
CHANGELOG.md header, llms-full.txt regen.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 17:14:53 -07:00

747 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* v0.32.3 search-lite mode bundles.
*
* Three named modes that bundle the search-lite knobs from PR #897 into a
* single config key so users pick once at install time and stop thinking
* about it. Each mode resolves to a complete knob set; per-call SearchOpts
* and per-key config overrides still win — mode just supplies the default.
*
* The resolution chain matches the v0.31.12 model-tier pattern at
* `src/core/model-config.ts:resolveModel`:
* per-call opts → per-key config → MODE_BUNDLES[cfg.search.mode] → MODE_BUNDLES.balanced
*
* `resolveSearchMode` is called at the top of bare `hybridSearch`, NOT just
* inside the `hybridSearchCached` wrapper. Eval commands (`eval replay`,
* `eval longmemeval`) call bare hybridSearch and must test the same
* mode-affected behavior as production. See `[CDX-5+6]` in the plan.
*
* `knobsHash` produces the SHA-256 the query cache uses to prevent
* cross-mode contamination. The PR #897 cache keyed only on
* (source_id, query_text) — a tokenmax run with expansion+limit=50 would
* populate a row that a subsequent conservative call reads back. Migration
* v56 adds `knobs_hash` column; lookup filters by knobs_hash equality AND
* embedding similarity. See `[CDX-4]` in the plan.
*/
import { createHash } from 'crypto';
export type SearchMode = 'conservative' | 'balanced' | 'tokenmax';
export const SEARCH_MODES: ReadonlyArray<SearchMode> = Object.freeze([
'conservative',
'balanced',
'tokenmax',
]);
/**
* A complete knob set for one mode. Every field is required so the bundle
* is self-contained and per-key overrides are obvious diffs.
*/
export interface ModeBundle {
/** Semantic query cache (PR #897). Free win; on for everyone. */
cache_enabled: boolean;
cache_similarity_threshold: number;
cache_ttl_seconds: number;
/** Zero-LLM intent classifier weight adjustments (PR #897). On for everyone. */
intentWeighting: boolean;
/**
* Per-call token budget cap (PR #897). undefined = no-op (tokenmax).
* 4000 = tight (conservative, fits Haiku context loop).
* 12000 = balanced (sweet-spot for Sonnet).
*/
tokenBudget: number | undefined;
/**
* LLM multi-query expansion (Haiku call per search).
* Per CLAUDE.md TODOS the corpus eval shows ~97.6% lift relative to no
* expansion — barely measurable. Off for conservative/balanced;
* on for tokenmax to preserve power-user retrieval ceiling.
*/
expansion: boolean;
/**
* Default `limit` for the operation layer (`src/core/operations.ts:1087`).
* Note: production `query` op TODAY defaults to 20. Mode bundle becomes
* the default ONLY when the caller omits the field — same chain semantics
* as model-tier resolution. See `[CDX-1+2+3]` in the plan: the original
* "tokenmax preserves Garry's setup" framing is wrong; tokenmax is an
* EXPANSION from the implicit current default (limit 20).
*/
searchLimit: number;
/**
* v0.35.0.0+ — cross-encoder reranker. Off for conservative/balanced,
* on for tokenmax. ZeroEntropy zerank-2 by default; can be overridden
* via `search.reranker.model`. Slots between dedup and token-budget
* enforcement in hybrid.ts; fail-open on any RerankError (audit-logged).
* Cost anchor: ~$0.0003/query at tokenmax topNIn=30 × ~400 tokens/chunk
* (rounding error vs Opus, meaningful vs Haiku).
*/
reranker_enabled: boolean;
/**
* Provider:model for the reranker. Default `'zeroentropyai:zerank-2'`.
* Other ZE rerankers (`zerank-1`, `zerank-1-small`) work via the same
* recipe; future Cohere/Voyage rerankers drop in as new recipes
* declaring `touchpoints.reranker`.
*/
reranker_model: string;
/** Candidates to send upstream (default 30). The full result list always
* reaches the user — topNIn just caps API spend on the rerank call. */
reranker_top_n_in: number;
/**
* Truncate the reranked output to this many. `null` = no truncate; the
* caller's `limit` is what trims final output. Distinct from undefined
* (which would fall through to mode bundle) — `null` is the explicit
* "don't truncate" signal, see CDX2-F15+F16.
*/
reranker_top_n_out: number | null;
/** HTTP timeout in ms (default 5000). Threaded into gateway.rerank. */
reranker_timeout_ms: number;
/**
* v0.35.6.0 — floor-ratio gate for metadata-axis boost stages (backlink,
* salience, recency). `undefined` = no gate (default for all three modes;
* preserves prior behavior bit-for-bit). When set to a number in [0, 1],
* each gated stage skips results whose score is below
* `floorRatio * topScore`, where topScore is computed ONCE at
* runPostFusionStages entry from the post-cosine-rescore snapshot.
*
* Sensible operator override values for dense-embedder corpora: 0.85-0.95.
* Default stays undefined until per-corpus ablation evidence supports a
* mode-level default. See `TODOS.md` floor-ratio ablation entry.
*
* Scoped to the three metadata boost stages — exact-match boost
* (intent-weights.applyExactMatchBoost) runs independently as a lexical
* relevance signal and is NOT gated.
*/
floor_ratio: number | undefined;
// v0.36 cross-modal wave knobs (D2 + D3 + D6 + D8 + D13 + LLM-intent).
// All three mode bundles default these to the same values — cross-modal
// is opt-in per-call (D6 weighting), opt-in per-brain (D8 unified flags),
// and opt-in per-feature-flag (LLM intent). The mode bundle just gives
// resolveSearchMode a default to return.
/**
* D6 'both'-mode RRF weight for text-vector results when merging
* text + image searches in parallel. Defaults to 0.6 — biases toward
* text recall because most queries with ambiguous modality are still
* text-leaning. Pair with cross_modal_both_image_weight.
*/
cross_modal_both_text_weight: number;
/**
* D6 'both'-mode RRF weight for image-vector results. Defaults to 0.4.
* Sum with text weight does NOT need to be 1.0 — RRF is rank-based, so
* weights normalize internally; the ratio is what matters.
*/
cross_modal_both_image_weight: number;
/**
* D13 image-query text-refinement RRF weight for the TEXT branch of
* searchByImage when the caller provides an optional `query` refinement.
* Defaults to 0.4 (image-dominant since the caller chose image-first).
*/
image_query_text_refinement_weight: number;
/**
* D13 image-query refinement RRF weight for the IMAGE branch. Defaults to 0.6.
*/
image_query_image_refinement_weight: number;
/**
* D8 Phase 3 flag: route ALL queries through the multimodal query embed
* + `embedding_multimodal` column. Default false. Operator opt-in after
* `gbrain reindex --multimodal` populates the unified column.
*/
unified_multimodal: boolean;
/**
* D8 Phase 3 strict mode: when true, the dual-column fallback path is
* bypassed entirely. Used by operators who finished re-embedding and
* want to commit to the unified space. Doctor surface errors when this
* is on and coverage < 99%.
*/
unified_multimodal_only: boolean;
/**
* Commit 4: opt-in LLM tie-break for ambiguous modality classification.
* Default false. When true, queries where regex returns 'text' but the
* ambiguity heuristic fires get a Haiku call to refine the classification.
* Fires for <1% of queries when on; ~$0.0001 per escalation.
*/
cross_modal_llm_intent: boolean;
}
/**
* The three mode bundles. Frozen at import time so a typo can't redefine
* "conservative" to mean different things on different installs — the
* public eval table depends on these being canonical. Power-user
* customization happens via per-key config overrides; if there's real
* demand for a custom bundle, that's a v0.34 conversation.
*/
export const MODE_BUNDLES: Readonly<Record<SearchMode, Readonly<ModeBundle>>> = Object.freeze({
conservative: Object.freeze({
cache_enabled: true,
cache_similarity_threshold: 0.92,
cache_ttl_seconds: 3600,
intentWeighting: true,
tokenBudget: 4000,
expansion: false,
searchLimit: 10,
// v0.35.0.0+: reranker off — conservative is cost-sensitive; reranker
// spend doesn't fit the tier's value prop.
reranker_enabled: false,
reranker_model: 'zeroentropyai:zerank-2',
reranker_top_n_in: 30,
reranker_top_n_out: null,
reranker_timeout_ms: 5000,
// v0.35.6.0 — undefined for all three bundles; the per-corpus ablation
// (TODOS.md) gates any default flip.
floor_ratio: undefined,
// v0.36 cross-modal defaults (same across all modes — opt-in)
cross_modal_both_text_weight: 0.6,
cross_modal_both_image_weight: 0.4,
image_query_text_refinement_weight: 0.4,
image_query_image_refinement_weight: 0.6,
unified_multimodal: false,
unified_multimodal_only: false,
cross_modal_llm_intent: false,
}),
balanced: Object.freeze({
cache_enabled: true,
cache_similarity_threshold: 0.92,
cache_ttl_seconds: 3600,
intentWeighting: true,
tokenBudget: 12000,
expansion: false,
searchLimit: 25,
// v0.36.0.0 (D6): reranker flipped ON for `balanced` mode bundle. The
// real-corpus benchmark shows zerank-2 reshuffles 60% of top-1 results
// — the headline ZE quality story reaches the 80% of installs that
// stay on `balanced`. Per-query rerank cost ~$0.025/M tokens, ~150ms
// p50 added latency. Missing ZEROENTROPY_API_KEY is handled via
// src/core/search/rerank.ts fail-open contract: log to audit JSONL,
// return input order unchanged. Opt out with
// `gbrain config set search.reranker.enabled false`.
reranker_enabled: true,
reranker_model: 'zeroentropyai:zerank-2',
reranker_top_n_in: 30,
reranker_top_n_out: null,
reranker_timeout_ms: 5000,
// v0.35.6.0 — undefined for all three bundles; the per-corpus ablation
// (TODOS.md) gates any default flip.
floor_ratio: undefined,
// v0.36 cross-modal defaults (same across all modes — opt-in)
cross_modal_both_text_weight: 0.6,
cross_modal_both_image_weight: 0.4,
image_query_text_refinement_weight: 0.4,
image_query_image_refinement_weight: 0.6,
unified_multimodal: false,
unified_multimodal_only: false,
cross_modal_llm_intent: false,
}),
tokenmax: Object.freeze({
cache_enabled: true,
cache_similarity_threshold: 0.92,
cache_ttl_seconds: 3600,
intentWeighting: true,
tokenBudget: undefined,
expansion: true,
searchLimit: 50,
// tokenmax is the high-cost-tolerant tier that already pays for LLM
// expansion + 50-result payloads. Reranker is the natural capstone:
// better ordering of a large candidate set is where rerankers earn
// their fee. ~$0.0003/query at this shape; rounding error vs the
// tier's $700/mo @ Opus pairing per CLAUDE.md cost matrix.
reranker_enabled: true,
reranker_model: 'zeroentropyai:zerank-2',
reranker_top_n_in: 30,
reranker_top_n_out: null,
reranker_timeout_ms: 5000,
// v0.35.6.0 — undefined for all three bundles; the per-corpus ablation
// (TODOS.md) gates any default flip.
floor_ratio: undefined,
// v0.36 cross-modal defaults (same across all modes — opt-in)
cross_modal_both_text_weight: 0.6,
cross_modal_both_image_weight: 0.4,
image_query_text_refinement_weight: 0.4,
image_query_image_refinement_weight: 0.6,
unified_multimodal: false,
unified_multimodal_only: false,
cross_modal_llm_intent: false,
}),
});
export const DEFAULT_SEARCH_MODE: SearchMode = 'balanced';
export function isSearchMode(x: unknown): x is SearchMode {
return typeof x === 'string' && (SEARCH_MODES as ReadonlyArray<string>).includes(x);
}
/**
* Per-key config overrides. Read at search-time from the `config` table.
* Every field is optional; an undefined field means "fall through to the
* mode bundle default."
*/
export interface SearchKeyOverrides {
cache_enabled?: boolean;
cache_similarity_threshold?: number;
cache_ttl_seconds?: number;
intentWeighting?: boolean;
tokenBudget?: number;
expansion?: boolean;
searchLimit?: number;
// v0.35.0.0+ reranker overrides
reranker_enabled?: boolean;
reranker_model?: string;
reranker_top_n_in?: number;
// CDX2-F16: null is the explicit "don't truncate" signal; undefined
// means "fall through to mode bundle". Use number | null, not
// number | undefined.
reranker_top_n_out?: number | null;
reranker_timeout_ms?: number;
// v0.35.6.0 — floor-ratio gate override.
floor_ratio?: number;
// v0.36 cross-modal overrides
cross_modal_both_text_weight?: number;
cross_modal_both_image_weight?: number;
image_query_text_refinement_weight?: number;
image_query_image_refinement_weight?: number;
unified_multimodal?: boolean;
unified_multimodal_only?: boolean;
cross_modal_llm_intent?: boolean;
}
/**
* Per-call opts that can override the bundle for this single search.
* Same shape as ModeBundle but every field is optional. These are passed
* through from `SearchOpts` / `HybridSearchOpts` so the existing per-call
* surface continues to work — mode just provides the default that the
* caller's explicit field overrides.
*/
export interface SearchPerCallOpts {
cache_enabled?: boolean;
cache_similarity_threshold?: number;
cache_ttl_seconds?: number;
intentWeighting?: boolean;
tokenBudget?: number;
expansion?: boolean;
searchLimit?: number;
// v0.35.0.0+ reranker per-call overrides (same shape as SearchKeyOverrides).
reranker_enabled?: boolean;
reranker_model?: string;
reranker_top_n_in?: number;
reranker_top_n_out?: number | null;
reranker_timeout_ms?: number;
// v0.35.6.0 — floor-ratio per-call override.
floor_ratio?: number;
// v0.36 cross-modal per-call overrides
cross_modal_both_text_weight?: number;
cross_modal_both_image_weight?: number;
image_query_text_refinement_weight?: number;
image_query_image_refinement_weight?: number;
unified_multimodal?: boolean;
unified_multimodal_only?: boolean;
cross_modal_llm_intent?: boolean;
}
/**
* Resolve the active search knob set for one search call.
*
* Resolution chain (matches v0.31.12 model-tier semantics):
* 1. perCallOpts.<key> if defined → wins
* 2. config.search.<key> if defined → wins
* 3. MODE_BUNDLES[config.search.mode].<key> → mode default
* 4. MODE_BUNDLES.balanced.<key> → safety fallback when config.search.mode is invalid/unset
*
* Pure function: no DB calls, no env reads. Caller pre-loads the relevant
* config rows (one SELECT for the whole batch of keys, not one per key).
*/
export interface ResolveSearchModeInput {
/** Resolved value of `config.search.mode`. Undefined → fallback to balanced. */
mode?: string;
/** Resolved per-key overrides from config table. */
overrides?: SearchKeyOverrides;
/** Per-call opts (SearchOpts / HybridSearchOpts). */
perCall?: SearchPerCallOpts;
}
export interface ResolvedSearchKnobs extends ModeBundle {
/** Which mode bundle supplied the defaults (after fallback). */
resolved_mode: SearchMode;
/** True if the caller's `mode` input was a recognized SearchMode. */
mode_valid: boolean;
}
export function resolveSearchMode(input: ResolveSearchModeInput): ResolvedSearchKnobs {
const requested = typeof input.mode === 'string' ? input.mode.trim().toLowerCase() : '';
const valid = isSearchMode(requested);
const resolved_mode: SearchMode = valid ? (requested as SearchMode) : DEFAULT_SEARCH_MODE;
const bundle = MODE_BUNDLES[resolved_mode];
const ov = input.overrides ?? {};
const pc = input.perCall ?? {};
const pick = <K extends keyof ModeBundle>(key: K): ModeBundle[K] => {
if (pc[key] !== undefined) return pc[key] as ModeBundle[K];
if (ov[key] !== undefined) return ov[key] as ModeBundle[K];
return bundle[key];
};
return {
cache_enabled: pick('cache_enabled'),
cache_similarity_threshold: pick('cache_similarity_threshold'),
cache_ttl_seconds: pick('cache_ttl_seconds'),
intentWeighting: pick('intentWeighting'),
tokenBudget: pick('tokenBudget'),
expansion: pick('expansion'),
searchLimit: pick('searchLimit'),
reranker_enabled: pick('reranker_enabled'),
reranker_model: pick('reranker_model'),
reranker_top_n_in: pick('reranker_top_n_in'),
reranker_top_n_out: pick('reranker_top_n_out'),
reranker_timeout_ms: pick('reranker_timeout_ms'),
// v0.35.6.0 — floor-ratio resolved via the same pick chain.
floor_ratio: pick('floor_ratio'),
// v0.36 cross-modal knobs
cross_modal_both_text_weight: pick('cross_modal_both_text_weight'),
cross_modal_both_image_weight: pick('cross_modal_both_image_weight'),
image_query_text_refinement_weight: pick('image_query_text_refinement_weight'),
image_query_image_refinement_weight: pick('image_query_image_refinement_weight'),
unified_multimodal: pick('unified_multimodal'),
unified_multimodal_only: pick('unified_multimodal_only'),
cross_modal_llm_intent: pick('cross_modal_llm_intent'),
resolved_mode,
mode_valid: valid,
};
}
/**
* Per-knob source attribution for `gbrain search modes` dashboard.
* Tells the user where each resolved value came from so override drift
* is legible. Mirrors `gbrain models` (v0.31.12) attribution shape.
*/
export type KnobSource = 'per-call' | 'override' | 'mode' | 'fallback';
export interface ResolvedKnobAttribution {
knob: keyof ModeBundle;
value: ModeBundle[keyof ModeBundle];
source: KnobSource;
// For 'override' source, the config key path; for 'mode' source, the mode name.
source_detail: string;
}
export function attributeKnob<K extends keyof ModeBundle>(
knob: K,
input: ResolveSearchModeInput,
resolved: ResolvedSearchKnobs,
): ResolvedKnobAttribution {
const pc = input.perCall ?? {};
const ov = input.overrides ?? {};
if (pc[knob] !== undefined) {
return { knob, value: resolved[knob], source: 'per-call', source_detail: 'SearchOpts' };
}
if (ov[knob] !== undefined) {
return { knob, value: resolved[knob], source: 'override', source_detail: `config: search.${knob}` };
}
if (resolved.mode_valid) {
return { knob, value: resolved[knob], source: 'mode', source_detail: `mode: ${resolved.resolved_mode}` };
}
return { knob, value: resolved[knob], source: 'fallback', source_detail: `mode: ${DEFAULT_SEARCH_MODE} (default — search.mode unset)` };
}
/**
* Stable hash of the resolved knob set. Used as part of the query_cache
* primary key so a tokenmax cache write can't be served to a conservative
* lookup (cross-mode contamination, [CDX-4]).
*
* Knob order is FIXED so the hash is deterministic across releases. NEVER
* reorder or add a knob without bumping a constant — a hash collision would
* mean stale cache rows silently reading the wrong shape.
*/
// v0.35.0.0+ bump 1→2: reranker fields participate in the cache key so a
// tokenmax-with-reranker write can't be served to a reranker-off lookup.
// v0.35.6.0 bump 2→3: floor_ratio participates so a floor-on write can't
// be served to a floor-off lookup (cross-floor contamination, codex T1).
// CDX2-F13 convention: under a version bump, additions are APPEND-ONLY at
// the end of `parts[]` — reordering existing fields would silently rebuild
// the hash for every existing row.
//
// CDX2-F12 mid-deploy duplicate-row note: because `cacheRowId()` (in
// src/core/search/query-cache.ts) includes knobsHash, a v=2 process and a
// v=3 process writing the same `(source_id, query_text)` produce DISTINCT
// row IDs. Expect a temporary hit-rate dip + cache-row doubling for hot
// queries during a rolling deploy. Clears naturally within
// `cache.ttl_seconds` (default 3600s). The CHANGELOG note covers this.
//
// v0.36 wave: cross-modal knobs ALSO participate in v=3 hash (D2 cache
// contamination fix — a text-mode cache hit cannot silently serve an
// image-mode caller). v0.35.6.0's floor_ratio bump and v0.36's cross-modal
// extensions both land under v=3, with cross-modal fields appended after
// the floor_ratio entry (CDX2-F13 append-only convention).
export const KNOBS_HASH_VERSION = 3;
/**
* v0.36 (D8 / CDX-2) — second-arg context for the cache key. The
* embedding column + provider live OUTSIDE ResolvedSearchKnobs because
* they're orthogonal to search mode (mode bundles don't pick columns).
* Passing them as a second argument keeps ModeBundle pure and lets the
* hash invalidate correctly across column/provider switches.
*
* When undefined, the hash falls back to the legacy 'embedding' /
* 'default' values so unrelated callers (eval-replay, telemetry) that
* don't know the column produce a stable hash for the default case.
*/
export interface KnobsHashContext {
/** Resolved column name, e.g. 'embedding', 'embedding_voyage'. */
embeddingColumn?: string;
/** Resolved provider:model, e.g. 'voyage:voyage-3-large'. */
embeddingModel?: string;
}
export function knobsHash(
knobs: ResolvedSearchKnobs,
ctx?: KnobsHashContext,
): string {
// Fixed-order key list. Adding a knob here REQUIRES bumping
// KNOBS_HASH_VERSION and is a breaking change for any persisted cache.
const parts = [
`v=${KNOBS_HASH_VERSION}`,
`mode=${knobs.resolved_mode}`,
`cache=${knobs.cache_enabled ? 1 : 0}`,
`sim=${knobs.cache_similarity_threshold.toFixed(4)}`,
`ttl=${knobs.cache_ttl_seconds}`,
`iw=${knobs.intentWeighting ? 1 : 0}`,
`tb=${knobs.tokenBudget ?? 'none'}`,
`exp=${knobs.expansion ? 1 : 0}`,
`lim=${knobs.searchLimit}`,
// v=2 additions (append-only).
`rr=${knobs.reranker_enabled ? 1 : 0}`,
`rrm=${knobs.reranker_model}`,
`rri=${knobs.reranker_top_n_in}`,
`rro=${knobs.reranker_top_n_out ?? 'none'}`,
`rrt=${knobs.reranker_timeout_ms}`,
// v=3 additions (append-only). Both contributions landed under v=3:
//
// floor_ratio (v0.35.6.0 / codex T1): a floor-on write must not be
// served to a floor-off lookup. 4-decimal precision so 0.85 and
// 0.851 produce different hashes; undefined uses literal 'none'.
//
// col + prov (v0.36 / D8 / CDX-2): cross-column + cross-provider
// cache contamination. A query against `embedding_voyage` must
// NEVER be served from a cache row that ran against `embedding`
// — they sit in different vector spaces. ctx is optional so
// unrelated callers fall back to the default-column hash.
`fr=${knobs.floor_ratio === undefined ? 'none' : knobs.floor_ratio.toFixed(4)}`,
// v=3 cross-modal additions (append-only).
`cmbt=${knobs.cross_modal_both_text_weight.toFixed(2)}`,
`cmbi=${knobs.cross_modal_both_image_weight.toFixed(2)}`,
`iqt=${knobs.image_query_text_refinement_weight.toFixed(2)}`,
`iqi=${knobs.image_query_image_refinement_weight.toFixed(2)}`,
`um=${knobs.unified_multimodal ? 1 : 0}`,
`umo=${knobs.unified_multimodal_only ? 1 : 0}`,
`lli=${knobs.cross_modal_llm_intent ? 1 : 0}`,
// v=3 column + provider additions (D8 / CDX-2): cross-column +
// cross-provider cache isolation. A query against `embedding_voyage`
// must never be served from a row that ran against `embedding`.
`col=${ctx?.embeddingColumn ?? 'embedding'}`,
`prov=${ctx?.embeddingModel ?? 'default'}`,
];
const h = createHash('sha256');
h.update(parts.join('|'));
return h.digest('hex').slice(0, 16);
}
/**
* Convenience: build SearchKeyOverrides from a flat config-table snapshot.
* Used by hybridSearch's hot path so the search code pays one round-trip
* to load all relevant config keys rather than one per knob.
*
* Returns sparse overrides — only keys actually present in the config
* map appear. Falsy/missing keys fall through to the mode bundle default.
*/
export function loadOverridesFromConfig(
configMap: Record<string, string | undefined>,
): SearchKeyOverrides {
const out: SearchKeyOverrides = {};
const get = (k: string): string | undefined => configMap[k];
const ce = get('search.cache.enabled');
if (ce !== undefined) {
out.cache_enabled = ce === '1' || ce.toLowerCase() === 'true';
}
const st = get('search.cache.similarity_threshold');
if (st !== undefined) {
const n = parseFloat(st);
if (Number.isFinite(n) && n > 0 && n <= 1) out.cache_similarity_threshold = n;
}
const tt = get('search.cache.ttl_seconds');
if (tt !== undefined) {
const n = parseInt(tt, 10);
if (Number.isFinite(n) && n > 0) out.cache_ttl_seconds = n;
}
const iw = get('search.intentWeighting');
if (iw !== undefined) {
out.intentWeighting = iw === '1' || iw.toLowerCase() === 'true';
}
const tb = get('search.tokenBudget');
if (tb !== undefined) {
const n = parseInt(tb, 10);
if (Number.isFinite(n) && n > 0) out.tokenBudget = n;
}
const ex = get('search.expansion');
if (ex !== undefined) {
out.expansion = ex === '1' || ex.toLowerCase() === 'true';
}
const sl = get('search.searchLimit');
if (sl !== undefined) {
const n = parseInt(sl, 10);
if (Number.isFinite(n) && n > 0) out.searchLimit = n;
}
// v0.35.0.0+ reranker overrides
const re = get('search.reranker.enabled');
if (re !== undefined) {
out.reranker_enabled = re === '1' || re.toLowerCase() === 'true';
}
const rm = get('search.reranker.model');
if (rm !== undefined && rm.trim().length > 0) {
out.reranker_model = rm.trim();
}
const ri = get('search.reranker.top_n_in');
if (ri !== undefined) {
const n = parseInt(ri, 10);
if (Number.isFinite(n) && n > 0) out.reranker_top_n_in = n;
}
// CDX2-F15 null parsing: top_n_out distinguishes three input shapes:
// key absent → undefined → fall through to mode bundle
// 'null' / 'none' / '' → explicit null (no truncate)
// positive integer → that number
const ro = get('search.reranker.top_n_out');
if (ro !== undefined) {
const trimmed = ro.trim().toLowerCase();
if (trimmed === '' || trimmed === 'null' || trimmed === 'none') {
out.reranker_top_n_out = null;
} else {
const n = parseInt(trimmed, 10);
if (Number.isFinite(n) && n > 0) out.reranker_top_n_out = n;
}
}
const rt = get('search.reranker.timeout_ms');
if (rt !== undefined) {
const n = parseInt(rt, 10);
if (Number.isFinite(n) && n > 0) out.reranker_timeout_ms = n;
}
// v0.35.6.0 — floor-ratio config key. Accepts a number in [0, 1]; values
// outside that range silently fall through (no override applied). The
// runtime computeFloorThreshold also guards against out-of-range so a
// malformed value never gates anything — defense in depth.
const fr = get('search.floor_ratio');
if (fr !== undefined) {
const n = parseFloat(fr);
if (Number.isFinite(n) && n >= 0 && n <= 1) out.floor_ratio = n;
}
// v0.36 cross-modal overrides (D3 registry)
const cmbt = get('search.cross_modal.both_mode_text_weight');
if (cmbt !== undefined) {
const n = parseFloat(cmbt);
if (Number.isFinite(n) && n >= 0) out.cross_modal_both_text_weight = n;
}
const cmbi = get('search.cross_modal.both_mode_image_weight');
if (cmbi !== undefined) {
const n = parseFloat(cmbi);
if (Number.isFinite(n) && n >= 0) out.cross_modal_both_image_weight = n;
}
const iqt = get('search.image_query.text_refinement_weight');
if (iqt !== undefined) {
const n = parseFloat(iqt);
if (Number.isFinite(n) && n >= 0) out.image_query_text_refinement_weight = n;
}
const iqi = get('search.image_query.image_refinement_weight');
if (iqi !== undefined) {
const n = parseFloat(iqi);
if (Number.isFinite(n) && n >= 0) out.image_query_image_refinement_weight = n;
}
const um = get('search.unified_multimodal');
if (um !== undefined) {
out.unified_multimodal = um === '1' || um.toLowerCase() === 'true';
}
const umo = get('search.unified_multimodal_only');
if (umo !== undefined) {
out.unified_multimodal_only = umo === '1' || umo.toLowerCase() === 'true';
}
const lli = get('search.cross_modal.llm_intent');
if (lli !== undefined) {
out.cross_modal_llm_intent = lli === '1' || lli.toLowerCase() === 'true';
}
return out;
}
/** The full list of config keys this module reads. Used by `gbrain search modes --reset`. */
export const SEARCH_MODE_CONFIG_KEYS: ReadonlyArray<string> = Object.freeze([
'search.cache.enabled',
'search.cache.similarity_threshold',
'search.cache.ttl_seconds',
'search.intentWeighting',
'search.tokenBudget',
'search.expansion',
'search.searchLimit',
// v0.35.0.0+ reranker keys
'search.reranker.enabled',
'search.reranker.model',
'search.reranker.top_n_in',
'search.reranker.top_n_out',
'search.reranker.timeout_ms',
// v0.35.6.0 — floor-ratio gate
'search.floor_ratio',
// v0.36 cross-modal keys (D3)
'search.cross_modal.both_mode_text_weight',
'search.cross_modal.both_mode_image_weight',
'search.image_query.text_refinement_weight',
'search.image_query.image_refinement_weight',
'search.unified_multimodal',
'search.unified_multimodal_only',
'search.cross_modal.llm_intent',
]);
/**
* The mode-selection config key itself. Separated from SEARCH_MODE_CONFIG_KEYS
* because `--reset` clears OVERRIDES (the per-knob keys) but should NOT clear
* the operator's mode choice.
*/
export const SEARCH_MODE_KEY = 'search.mode';
/**
* Load the live mode config (mode + per-key overrides) from the brain engine.
* Runs ONE round-trip per knob currently — the BrainEngine.getConfig interface
* is single-key. A future v0.34 batch loader can collapse this. Volume is
* small (~8 keys); call site is once per search.
*
* Errors are swallowed and fall through to mode-bundle defaults. The cache
* config table predates v0.32.3 and may not exist on very old brains, so
* silent fallback is the right shape.
*/
export async function loadSearchModeConfig(
engine: { getConfig(key: string): Promise<string | null> },
): Promise<ResolveSearchModeInput> {
const safeGet = async (k: string): Promise<string | undefined> => {
try {
const v = await engine.getConfig(k);
return v == null ? undefined : v;
} catch {
return undefined;
}
};
const [mode, ...overrideValues] = await Promise.all([
safeGet(SEARCH_MODE_KEY),
...SEARCH_MODE_CONFIG_KEYS.map(safeGet),
]);
const configMap: Record<string, string | undefined> = {};
SEARCH_MODE_CONFIG_KEYS.forEach((key, i) => {
if (overrideValues[i] !== undefined) configMap[key] = overrideValues[i];
});
return {
mode,
overrides: loadOverridesFromConfig(configMap),
};
}