Files
gbrain/test/search-mode.test.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

400 lines
16 KiB
TypeScript

/**
* Pins the v0.32.3 search-lite mode core: MODE_BUNDLES + resolveSearchMode
* + knobsHash. The 3x7 mode table is asserted cell-by-cell because the
* public eval methodology doc cites these values verbatim — drift here is
* a documentation-honesty bug, not a refactor.
*/
import { describe, expect, test } from 'bun:test';
import {
MODE_BUNDLES,
SEARCH_MODES,
DEFAULT_SEARCH_MODE,
isSearchMode,
resolveSearchMode,
attributeKnob,
knobsHash,
loadOverridesFromConfig,
KNOBS_HASH_VERSION,
SEARCH_MODE_CONFIG_KEYS,
type SearchMode,
} from '../src/core/search/mode.ts';
describe('SEARCH_MODES + MODE_BUNDLES canonical shape', () => {
test('SEARCH_MODES is exactly the 3 expected values', () => {
expect([...SEARCH_MODES]).toEqual(['conservative', 'balanced', 'tokenmax']);
});
test('DEFAULT_SEARCH_MODE is balanced (matches v0.31.x current default surface)', () => {
expect(DEFAULT_SEARCH_MODE).toBe('balanced');
});
test('MODE_BUNDLES is frozen (cannot be mutated)', () => {
expect(Object.isFrozen(MODE_BUNDLES)).toBe(true);
expect(Object.isFrozen(MODE_BUNDLES.conservative)).toBe(true);
expect(Object.isFrozen(MODE_BUNDLES.balanced)).toBe(true);
expect(Object.isFrozen(MODE_BUNDLES.tokenmax)).toBe(true);
});
// The cell-by-cell assertion. The methodology doc cites these.
// v0.35.0.0+ extended with 5 reranker fields. tokenmax flips reranker on;
// conservative + balanced keep it off until eval data backs a change.
// v0.36 cross-modal wave: shared defaults across all modes (opt-in).
const CROSS_MODAL_DEFAULTS = {
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,
};
test('conservative bundle values are canonical', () => {
expect(MODE_BUNDLES.conservative).toEqual({
cache_enabled: true,
cache_similarity_threshold: 0.92,
cache_ttl_seconds: 3600,
intentWeighting: true,
tokenBudget: 4000,
expansion: false,
searchLimit: 10,
reranker_enabled: false,
reranker_model: 'zeroentropyai:zerank-2',
reranker_top_n_in: 30,
reranker_top_n_out: null,
reranker_timeout_ms: 5000,
floor_ratio: undefined,
...CROSS_MODAL_DEFAULTS,
});
});
test('balanced bundle values are canonical', () => {
// v0.36.0.0 (D6): reranker_enabled flipped from false → true. The 60%
// top-1 reshuffle reaches the 80% of installs that stay on `balanced`.
expect(MODE_BUNDLES.balanced).toEqual({
cache_enabled: true,
cache_similarity_threshold: 0.92,
cache_ttl_seconds: 3600,
intentWeighting: true,
tokenBudget: 12000,
expansion: false,
searchLimit: 25,
reranker_enabled: true,
reranker_model: 'zeroentropyai:zerank-2',
reranker_top_n_in: 30,
reranker_top_n_out: null,
reranker_timeout_ms: 5000,
floor_ratio: undefined,
...CROSS_MODAL_DEFAULTS,
});
});
test('tokenmax bundle values are canonical (NOTE: limit=50, NOT current=20)', () => {
expect(MODE_BUNDLES.tokenmax).toEqual({
cache_enabled: true,
cache_similarity_threshold: 0.92,
cache_ttl_seconds: 3600,
intentWeighting: true,
tokenBudget: undefined,
expansion: true,
searchLimit: 50,
reranker_enabled: true,
reranker_model: 'zeroentropyai:zerank-2',
reranker_top_n_in: 30,
reranker_top_n_out: null,
reranker_timeout_ms: 5000,
floor_ratio: undefined,
...CROSS_MODAL_DEFAULTS,
});
});
test('cache_enabled is true in every mode (free win)', () => {
for (const m of SEARCH_MODES) {
expect(MODE_BUNDLES[m].cache_enabled).toBe(true);
}
});
test('intentWeighting is true in every mode (zero-LLM cost)', () => {
for (const m of SEARCH_MODES) {
expect(MODE_BUNDLES[m].intentWeighting).toBe(true);
}
});
test('tokenBudget escalates: 4000 → 12000 → undefined', () => {
expect(MODE_BUNDLES.conservative.tokenBudget).toBe(4000);
expect(MODE_BUNDLES.balanced.tokenBudget).toBe(12000);
expect(MODE_BUNDLES.tokenmax.tokenBudget).toBeUndefined();
});
test('searchLimit escalates: 10 → 25 → 50', () => {
expect(MODE_BUNDLES.conservative.searchLimit).toBe(10);
expect(MODE_BUNDLES.balanced.searchLimit).toBe(25);
expect(MODE_BUNDLES.tokenmax.searchLimit).toBe(50);
});
});
describe('isSearchMode', () => {
test('accepts every documented mode', () => {
for (const m of SEARCH_MODES) {
expect(isSearchMode(m)).toBe(true);
}
});
test('rejects unknown strings, numbers, null, undefined', () => {
expect(isSearchMode('conservativeX')).toBe(false);
expect(isSearchMode('')).toBe(false);
expect(isSearchMode('CONSERVATIVE')).toBe(false); // case-sensitive at the type guard layer
expect(isSearchMode(42)).toBe(false);
expect(isSearchMode(null)).toBe(false);
expect(isSearchMode(undefined)).toBe(false);
});
});
describe('resolveSearchMode resolution chain', () => {
test('no inputs → balanced bundle (fallback)', () => {
const r = resolveSearchMode({});
expect(r.resolved_mode).toBe('balanced');
expect(r.mode_valid).toBe(false);
expect(r.searchLimit).toBe(25);
expect(r.tokenBudget).toBe(12000);
expect(r.expansion).toBe(false);
});
test('valid mode picked, no overrides → bundle values pass through', () => {
const r = resolveSearchMode({ mode: 'conservative' });
expect(r.resolved_mode).toBe('conservative');
expect(r.mode_valid).toBe(true);
expect(r.searchLimit).toBe(10);
expect(r.tokenBudget).toBe(4000);
});
test('invalid mode string → balanced fallback (mode_valid=false)', () => {
const r = resolveSearchMode({ mode: 'NUKE_MODE' });
expect(r.resolved_mode).toBe('balanced');
expect(r.mode_valid).toBe(false);
expect(r.searchLimit).toBe(25);
});
test('mode string case-normalized (TokenMax → tokenmax)', () => {
const r = resolveSearchMode({ mode: 'TokenMax' });
expect(r.resolved_mode).toBe('tokenmax');
expect(r.mode_valid).toBe(true);
});
test('per-key override wins over mode bundle (CDX-5 chain)', () => {
const r = resolveSearchMode({
mode: 'conservative',
overrides: { tokenBudget: 99999, cache_enabled: false },
});
expect(r.resolved_mode).toBe('conservative');
expect(r.tokenBudget).toBe(99999);
expect(r.cache_enabled).toBe(false);
expect(r.searchLimit).toBe(10); // not overridden, still from bundle
});
test('per-call override wins over per-key override', () => {
const r = resolveSearchMode({
mode: 'conservative',
overrides: { tokenBudget: 99999 },
perCall: { tokenBudget: 77 },
});
expect(r.tokenBudget).toBe(77);
});
test('per-call false-y values (false / 0) still beat fallback', () => {
const r = resolveSearchMode({
mode: 'tokenmax',
perCall: { expansion: false, cache_enabled: false },
});
expect(r.expansion).toBe(false); // beat tokenmax's true
expect(r.cache_enabled).toBe(false); // beat tokenmax's true
});
test('undefined fields in perCall fall through (not coerced to false)', () => {
const r = resolveSearchMode({
mode: 'tokenmax',
perCall: { tokenBudget: undefined, expansion: undefined },
});
expect(r.tokenBudget).toBeUndefined(); // from tokenmax bundle
expect(r.expansion).toBe(true); // from tokenmax bundle, NOT overridden
});
});
describe('attributeKnob source attribution', () => {
test('per-call source labeled correctly', () => {
const input = { mode: 'conservative', perCall: { tokenBudget: 999 } };
const resolved = resolveSearchMode(input);
const a = attributeKnob('tokenBudget', input, resolved);
expect(a.source).toBe('per-call');
expect(a.value).toBe(999);
});
test('override source labels the config key path', () => {
const input = { mode: 'conservative', overrides: { cache_enabled: false } };
const resolved = resolveSearchMode(input);
const a = attributeKnob('cache_enabled', input, resolved);
expect(a.source).toBe('override');
expect(a.source_detail).toContain('search.cache_enabled');
});
test('mode source labels the mode name', () => {
const input = { mode: 'conservative' };
const resolved = resolveSearchMode(input);
const a = attributeKnob('searchLimit', input, resolved);
expect(a.source).toBe('mode');
expect(a.source_detail).toContain('conservative');
});
test('fallback source labels the unset state explicitly', () => {
const input = {}; // no mode set
const resolved = resolveSearchMode(input);
const a = attributeKnob('searchLimit', input, resolved);
expect(a.source).toBe('fallback');
expect(a.source_detail).toContain('balanced');
expect(a.source_detail).toContain('unset');
});
});
describe('knobsHash determinism + cross-mode separation (CDX-4)', () => {
test('hash is deterministic across calls', () => {
const knobs = resolveSearchMode({ mode: 'conservative' });
const h1 = knobsHash(knobs);
const h2 = knobsHash(knobs);
expect(h1).toBe(h2);
});
test('different modes produce different hashes', () => {
const c = knobsHash(resolveSearchMode({ mode: 'conservative' }));
const b = knobsHash(resolveSearchMode({ mode: 'balanced' }));
const t = knobsHash(resolveSearchMode({ mode: 'tokenmax' }));
expect(c).not.toBe(b);
expect(b).not.toBe(t);
expect(c).not.toBe(t);
});
test('per-call override changes the hash (cache key bifurcates)', () => {
const a = knobsHash(resolveSearchMode({ mode: 'conservative' }));
const b = knobsHash(resolveSearchMode({ mode: 'conservative', perCall: { tokenBudget: 999 } }));
expect(a).not.toBe(b);
});
test('hash is short (16 hex chars) and stable shape', () => {
const h = knobsHash(resolveSearchMode({ mode: 'balanced' }));
expect(h).toMatch(/^[0-9a-f]{16}$/);
});
test('KNOBS_HASH_VERSION constant exposed for migrations to bump on schema change', () => {
// v0.35.0.0+ bumped 1→2 to fold reranker fields into the cache key.
// v0.35.6.0 bumped 2→3 to fold floor_ratio (codex outside-voice T1 —
// preventing cross-floor cache contamination).
// v0.36 piggybacks on v=3 with 7 additional cross-modal knobs (D2) PLUS
// embedding column + provider context (D8/CDX-2 cross-column isolation),
// all appended per CDX2-F13 append-only convention so a text-mode cache
// hit can never silently serve to an image-mode caller, and a query
// against `embedding_voyage` never shares a cache row with `embedding`.
expect(KNOBS_HASH_VERSION).toBe(3);
});
test('T1 (codex): floor_ratio set vs unset produces DIFFERENT hashes (cache contamination prevention)', () => {
// Without this, a no-floor write would be served to a floor-enabled read
// — direct ranking-correctness leak. Same bug class CDX-4 closed in v0.32.3
// for the other search-lite knobs.
const noFloor = knobsHash(resolveSearchMode({ mode: 'balanced' }));
const withFloor = knobsHash(resolveSearchMode({ mode: 'balanced', perCall: { floor_ratio: 0.85 } }));
expect(noFloor).not.toBe(withFloor);
});
test('T1 (codex): different floor_ratio values produce different hashes', () => {
// 0.85 and 0.90 are distinct cache rows. 4-decimal precision in the hash
// input means 0.85 and 0.851 also differ (consumers tuning by hundredths
// get a clean cache split).
const a = knobsHash(resolveSearchMode({ mode: 'balanced', perCall: { floor_ratio: 0.85 } }));
const b = knobsHash(resolveSearchMode({ mode: 'balanced', perCall: { floor_ratio: 0.90 } }));
expect(a).not.toBe(b);
});
test('same floor_ratio produces same hash (idempotent cache key)', () => {
const a = knobsHash(resolveSearchMode({ mode: 'balanced', perCall: { floor_ratio: 0.85 } }));
const b = knobsHash(resolveSearchMode({ mode: 'balanced', perCall: { floor_ratio: 0.85 } }));
expect(a).toBe(b);
});
});
describe('loadOverridesFromConfig flat-map parser', () => {
test('empty config map → empty overrides', () => {
const ov = loadOverridesFromConfig({});
expect(ov).toEqual({});
});
test('cache.enabled accepts 1 / 0 / true / false strings', () => {
expect(loadOverridesFromConfig({ 'search.cache.enabled': '1' }).cache_enabled).toBe(true);
expect(loadOverridesFromConfig({ 'search.cache.enabled': '0' }).cache_enabled).toBe(false);
expect(loadOverridesFromConfig({ 'search.cache.enabled': 'true' }).cache_enabled).toBe(true);
expect(loadOverridesFromConfig({ 'search.cache.enabled': 'false' }).cache_enabled).toBe(false);
expect(loadOverridesFromConfig({ 'search.cache.enabled': 'TRUE' }).cache_enabled).toBe(true);
});
test('numeric keys parse and clamp', () => {
expect(loadOverridesFromConfig({ 'search.cache.similarity_threshold': '0.95' }).cache_similarity_threshold).toBe(0.95);
expect(loadOverridesFromConfig({ 'search.cache.ttl_seconds': '7200' }).cache_ttl_seconds).toBe(7200);
expect(loadOverridesFromConfig({ 'search.tokenBudget': '8000' }).tokenBudget).toBe(8000);
expect(loadOverridesFromConfig({ 'search.searchLimit': '30' }).searchLimit).toBe(30);
});
test('invalid numerics are ignored (not coerced to NaN/0)', () => {
const ov = loadOverridesFromConfig({
'search.cache.similarity_threshold': 'NaN',
'search.tokenBudget': 'cheese',
'search.searchLimit': '-1',
'search.cache.ttl_seconds': '0',
});
expect(ov.cache_similarity_threshold).toBeUndefined();
expect(ov.tokenBudget).toBeUndefined();
expect(ov.searchLimit).toBeUndefined();
expect(ov.cache_ttl_seconds).toBeUndefined();
});
test('similarity_threshold rejects values outside (0, 1]', () => {
expect(loadOverridesFromConfig({ 'search.cache.similarity_threshold': '1.5' }).cache_similarity_threshold).toBeUndefined();
expect(loadOverridesFromConfig({ 'search.cache.similarity_threshold': '0' }).cache_similarity_threshold).toBeUndefined();
expect(loadOverridesFromConfig({ 'search.cache.similarity_threshold': '-0.1' }).cache_similarity_threshold).toBeUndefined();
});
test('v0.35.6.0: floor_ratio parses valid 0..1 values', () => {
expect(loadOverridesFromConfig({ 'search.floor_ratio': '0.85' }).floor_ratio).toBe(0.85);
expect(loadOverridesFromConfig({ 'search.floor_ratio': '0' }).floor_ratio).toBe(0);
expect(loadOverridesFromConfig({ 'search.floor_ratio': '1' }).floor_ratio).toBe(1);
expect(loadOverridesFromConfig({ 'search.floor_ratio': '0.5' }).floor_ratio).toBe(0.5);
});
test('v0.35.6.0: floor_ratio rejects out-of-range values silently', () => {
expect(loadOverridesFromConfig({ 'search.floor_ratio': '-0.1' }).floor_ratio).toBeUndefined();
expect(loadOverridesFromConfig({ 'search.floor_ratio': '1.5' }).floor_ratio).toBeUndefined();
expect(loadOverridesFromConfig({ 'search.floor_ratio': 'NaN' }).floor_ratio).toBeUndefined();
expect(loadOverridesFromConfig({ 'search.floor_ratio': 'cheese' }).floor_ratio).toBeUndefined();
});
});
describe('SEARCH_MODE_CONFIG_KEYS is the full reset surface', () => {
test('every key starts with search. prefix (gbrain config unset --pattern search.* compatibility)', () => {
for (const k of SEARCH_MODE_CONFIG_KEYS) {
expect(k.startsWith('search.')).toBe(true);
}
});
test('every ModeBundle field has a config key (consistency check)', () => {
// If a new knob is added to ModeBundle, this test fails until the operator
// adds the corresponding config key to SEARCH_MODE_CONFIG_KEYS. That's the
// intentional regression guard: `gbrain search modes --reset` must clear
// every knob.
const knobs = Object.keys(MODE_BUNDLES.balanced);
expect(SEARCH_MODE_CONFIG_KEYS.length).toBeGreaterThanOrEqual(knobs.length);
});
});
describe('Type-only smoke test (compiler sees SearchMode union)', () => {
test('SearchMode union is exactly 3 modes (compile-time)', () => {
const valid: SearchMode[] = ['conservative', 'balanced', 'tokenmax'];
expect(valid.length).toBe(3);
});
});