mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
* 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>
747 lines
30 KiB
TypeScript
747 lines
30 KiB
TypeScript
/**
|
||
* 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),
|
||
};
|
||
}
|
||
|