mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* feat(ai): add ZeroEntropy recipe + reranker touchpoint type
Widens `TouchpointKind` with `'reranker'`, adds `RerankerTouchpoint`
interface, extends `Recipe.touchpoints` and `AIGatewayConfig` to carry
reranker model state. Registers `zeroentropyai` recipe (zembed-1
embeddings + zerank-{2,1,1-small} rerankers) in the recipe registry.
Recipe declares the 7 Matryoshka dims (2560/1280/640/320/160/80/40),
Voyage-style dense-payload hedge (chars_per_token=1, safety_factor=0.5),
and 5MB rerank payload cap. Pinned by test/ai/zeroentropy-recipe.test.ts
including F1 regression (implementation literal is 'openai-compatible')
and F2 regression (base_url_default ends with /v1, no doubling).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(ai/dims): thread input_type 4th-arg + ZE flexible-dim allowlist
`dimsProviderOptions` gains an optional `inputType?: 'query' | 'document'`
4th param so asymmetric providers (ZE zembed-1, Voyage v3+) can route
query-side vs document-side encoding. Per-model filtering inside the
openai-compatible branch keeps `input_type` from leaking to symmetric
providers (OpenAI text-3, DashScope, Zhipu) that would 400 on it.
Adds `ZEROENTROPY_VALID_DIMS` allowlist (2560/1280/640/320/160/80/40),
`supportsZeroEntropyDimension(modelId)`, and `isValidZeroEntropyDim(dims)`.
Throws `AIConfigError` with paste-ready fix hint when zembed-1 is
configured with an invalid dim (most common: defaulting to 1536 from
DEFAULT_EMBEDDING_DIMENSIONS).
The 4th-arg is optional; existing call sites (1 production + N tests
across Voyage/OpenAI/DashScope/Zhipu/MiniMax) compile unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(ai/gateway): zeroEntropyCompatFetch + embedQuery + gateway.rerank()
Two seams land together because they share the same recipe + auth path.
zeroEntropyCompatFetch handles ZE's non-OpenAI-compatible wire shape:
- URL rewrite: SDK's `${base_url}/embeddings` -> `${base_url}/models/embed`
- Body inject: `input_type` (default 'document'; 'query' when threaded
via providerOptions) + explicit `encoding_format: 'float'`
- Response rewrite: `{results: [{embedding}]}` -> `{data: [{embedding,
index}]}` so the AI SDK's openai-compat schema validates
- `usage.prompt_tokens` injected from `total_tokens` (Voyage hit the
same SDK schema requirement at :655)
- Layer 1 (Content-Length) + Layer 2 (per-embedding size) OOM caps
via tagged `ZeroEntropyResponseTooLargeError` (kept separate from
`VoyageResponseTooLargeError` because the Voyage cap tests do
structural source-text greps pinning the Voyage name)
- Wired in `instantiateEmbedding()` via the existing
`recipe.id === 'voyage' ? voyageCompatFetch : ...` ternary pattern
embedQuery(text) routes `inputType: 'query'` through dimsProviderOptions
for the search hot path. Companion to embed(texts) which now takes an
optional 2nd-arg inputType (defaults to undefined -> 'document' for
asymmetric providers).
gateway.rerank() is the new native HTTP path (no AI-SDK reranking
abstraction). Resolves the configured reranker model via
`getRerankerModel()` (new accessor), parses + asserts the model is in
the recipe's touchpoint.reranker.models allowlist (CDX2-F11:
assertTouchpoint does not enforce allowlists for openai-compatible
recipes — rerank() does it directly). Posts to
`${recipe.base_url}/models/rerank` with bearer auth. Returns
`RerankResult[]` sorted by `relevanceScore`. Errors classify into
`RerankError.reason: 'auth' | 'rate_limit' | 'network' | 'timeout' |
'payload_too_large' | 'unknown'`. 5s default timeout. Pre-flight payload
guard rejects bodies over `recipe.max_payload_bytes` BEFORE any HTTP
call so applyReranker can fail-open without burning a round-trip.
`_rerankTransport` + `__setRerankTransportForTests` mirror the embed
test seam.
`AIGatewayConfig.reranker_model` + isAvailable('reranker') branch +
configureGateway / reconfigureGatewayWithEngine extensions thread the
reranker model through the same state path as embedding/expansion/chat.
`applyResolveAuth` + `defaultResolveAuth` widen the touchpoint param to
include `'reranker'`. `KnownTouchpointKey` + `getTouchpoint()` in
model-resolver widen to cover `'reranker'`.
Pinned by:
- test/ai/embedQuery.test.ts (8): returns single Float32Array, threads
input_type='query' for ZE, drops field for OpenAI text-3,
back-compat: legacy embed() callers without 4th arg keep their
previous Voyage no-input_type shape
- test/ai/rerank.test.ts (21): URL (F2 regression — no /v1/v1/), body
shape, bearer header, response parsing, error classification across
6 HTTP shapes, payload pre-flight (no transport call), allowlist
enforcement
- test/ai/zeroentropy-compat-fetch.test.ts (14): structural source
assertions for the shim that mirror test/voyage-response-cap.test.ts —
URL rewrite path, body injection, response rewrite, usage.prompt_tokens
injection, OOM caps Layer 1 + Layer 2 + instanceof rethrow,
instantiateEmbedding wiring branch
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(search): applyReranker + rerank-failure audit + hybrid wire-in
src/core/search/rerank.ts — the call-site abstraction. Slices the top
`opts.topNIn` deduped candidates, sends to gateway.rerank(), reorders by
relevanceScore desc, appends the un-reranked tail in its original RRF
order (recall protection). Fail-open on every RerankError.reason: logs
via `logRerankFailure` and returns the input array unchanged. Stamps
`rerank_score` onto reordered items. `topNOut: null` is the explicit
"don't truncate" signal — distinct from `undefined` (fall through to
mode bundle); pin in test (CDX2-F16).
src/core/rerank-audit.ts — failure-only JSONL audit at
`~/.gbrain/audit/rerank-failures-YYYY-Www.jsonl` (ISO-week rotation;
mirrors `src/core/audit-slug-fallback.ts`). Exports `logRerankFailure`
+ `readRecentRerankFailures(days)`. **No `logRerankSuccess`** — CDX2-F22
deliberately drops success-event logging: writing once per tokenmax
search is hot-path I/O churn AND success events leak query
volume + timing into a local audit. The doctor check reads
`search.reranker.enabled` first so "no events in window" gets
interpreted correctly (disabled -> healthy by definition; enabled ->
healthy because nothing failed). Query text is SHA-256-prefix-hashed
(8 hex chars) for privacy. Honors `GBRAIN_AUDIT_DIR`.
src/core/search/hybrid.ts — slots `applyReranker` between
`dedupResults()` and `enforceTokenBudget()` in the main RRF path.
Resolution: per-call `opts.reranker` overrides; otherwise pulled from
the resolved mode bundle (tokenmax -> enabled, others -> disabled in
commit 5). Cache rows store final reranked results; the bumped
knobsHash (commit 5) ensures rows can't leak across reranker configs.
src/core/types.ts — adds `SearchOpts.reranker` as a structural type so
callers can pass per-call overrides; runtime type lives in
src/core/search/rerank.ts (avoids circular import).
Tests:
- test/search/rerank.test.ts (14): reorder, tail preserve, fail-open on
every error class, topNOut null vs number, score stamping, empty +
enabled=false pass-through
- test/rerank-audit.test.ts (10): JSONL round-trip, error_summary
truncated to 200, corrupt rows skipped, missing dir -> [], ISO-week
rotation walks current + previous week, no logRerankSuccess export
(CDX2-F22 contract)
- test/search/hybrid-reranker-integration.test.ts (6): reranker fires
when enabled, doesn't when disabled, reorders correctly, preserves
tail, stamps rerank_score, fail-opens on rerankerFn throw — uses
PGLite + stubbed embed transport, no API keys
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(search/mode): reranker mode-bundle fields + KNOBS_HASH_VERSION v=2
Extends `ModeBundle` with five reranker fields: `reranker_enabled`,
`reranker_model`, `reranker_top_n_in`, `reranker_top_n_out`,
`reranker_timeout_ms`. Per-mode defaults:
- conservative -> enabled=false (cost-sensitive)
- balanced -> enabled=false (opt-in via search.reranker.enabled)
- tokenmax -> enabled=true (the high-cost-tolerant tier; ~$0.0003/query)
Defaults model to `zeroentropyai:zerank-2`, topNIn=30, topNOut=null
(no truncate by default; preserves tokenmax's searchLimit=50 end-to-end
per CDX2-F16), timeout_ms=5000.
`SearchKeyOverrides` + `SearchPerCallOpts` + `resolveSearchMode.pick`
all extend to thread the new fields through the resolution chain
(per-call -> per-key config -> mode bundle -> default).
`loadOverridesFromConfig` adds parsers for the five new
`search.reranker.*` config keys. `top_n_out` parsing distinguishes
three input shapes (CDX2-F15):
key absent -> undefined (fall through to mode bundle)
'null'|'none'|empty -> explicit null (no truncate)
positive integer -> that number
`SEARCH_MODE_CONFIG_KEYS` extends so `gbrain search modes --reset`
clears the reranker overrides too.
**KNOBS_HASH_VERSION bumps 1 -> 2** (CDX1-F14). Five new entries
appended to `parts[]` (append-only convention CDX2-F13; reordering
existing fields would silently rebuild every existing cache row).
Includes `reranker_timeout_ms` so a 5s -> 100ms change invalidates
stale rows (CDX2-F14: more fail-opens = different search behavior).
Mid-rolling-deploy note (CDX2-F12): v=1 and v=2 processes produce
distinct cacheRowIds for the same (source_id, query_text). Expect a
temporary hit-rate dip + cache-row doubling for hot queries. Clears
naturally within `cache.ttl_seconds` (default 3600s).
src/commands/search.ts extends `KNOB_DESCRIPTIONS` with five new
entries so `gbrain search modes` renders them. test/search-mode.test.ts
extends the three bundle fixtures and bumps the KNOBS_HASH_VERSION
expectation to 2.
Pinned by test/search/knobs-hash-reranker.test.ts (13): each of the 5
reranker fields independently flips the hash, top_n_out=null renders
stable, append-only convention enforced via source-position assertion.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(doctor): probeRerankerConfig + reranker_health check
`gbrain models doctor` gains two new probes:
- `probeRerankerConfig` (zero-network) validates that the configured
reranker model resolves through the recipe registry, that the recipe
declares a `reranker` touchpoint, and that the model is in
`touchpoint.models[]`. Direct allowlist check here — assertTouchpoint
does not enforce allowlists for openai-compatible recipes (CDX2-F11).
Surfaces paste-ready `gbrain config set search.reranker.model
<zerank-2|zerank-1|zerank-1-small>` fix hint.
- `probeRerankerReachability` (1-token-equivalent) sends a minimal
`{query: "probe", documents: ["probe"]}` rerank to verify auth + URL.
Failures classify via `classifyError` into auth/rate_limit/network/
unknown. Skipped silently when reranker is unconfigured.
Also extends `probeEmbeddingConfig` with a `providerId === 'zeroentropyai'`
branch that catches the silent-1536-default bug class for zembed-1
configurations (same posture as the existing Voyage branch).
`ProbeResult.touchpoint` widens to include `'reranker_config'`.
`gbrain doctor` adds `checkRerankerHealth` to both the abbreviated
(doctorReportRemote) and full (runDoctor) check sets. Logic:
1) Read `search.reranker.enabled` first. Disabled + no failures =>
'reranker disabled'. Enabled + no failures => healthy.
2) Walk last 7 days of ~/.gbrain/audit/rerank-failures-*.jsonl.
3) ANY auth failure warns (config-time problem the probe should have
caught — surface it).
4) ANY payload_too_large failure warns (workload mismatch).
5) Transient (network/timeout/rate_limit) warns at >=5 in window.
Below that they're noise; reranker fails open anyway.
CDX2-F21 blind-spot fix: reading enabled state first means "no events"
gets interpreted correctly — never confuses "never-used" with "success
logging broken" (the latter is impossible because there is no success
logging by design, CDX2-F22).
Engine-agnostic; file-based + one config-key read.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(e2e): ZeroEntropy live API round-trip + wire into Tier 2 CI
test/e2e/zeroentropy-live.test.ts exercises the full stack against the
real api.zeroentropy.dev: embed (default 2560-dim + flexible 1280),
embedQuery (asymmetric query side), batch embed (3 distinct vectors),
rerank (3 docs sorted by relevance score, photosynthesis-relevant docs
beat the irrelevant cat doc), rerank with topN truncation.
Gated on `ZEROENTROPY_API_KEY`: every test prints `[skip]` and returns
early without assertions when the env var is unset, so fork PRs and
contributor machines without a ZE account stay green.
CI wire-up: `.github/workflows/e2e.yml` Tier 2 step adds
`test/e2e/zeroentropy-live.test.ts` to its `bun test` invocation and
exposes `ZEROENTROPY_API_KEY: ${{ secrets.ZEROENTROPY_API_KEY }}` to
the runner. The secret is set on garrytan/gbrain at the repo scope
(separately from this commit — set via `gh secret set` so the value
never lands in source).
Tier 1 stays mechanical (no API keys); Tier 2 is the natural home for
provider-live tests because it's already the API-keyed lane.
Cost: each full run fires ~6 small HTTP calls totaling well under a
cent at the published $0.025/1M-token rate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* v0.33.3.0 feat: ZeroEntropy zembed-1 + zerank-2 reranker
Release notes for the ZeroEntropy support wave: zembed-1 embeddings
(flexible-dim 2560/1280/640/320/160/80/40, asymmetric input_type) and
zerank-2 cross-encoder reranking land as a new openai-compatible recipe
alongside OpenAI/Voyage. Reranker defaults ON for tokenmax mode, OFF
for conservative/balanced (~$0.0003/query at tokenmax topNIn=30; rounding
error vs the tier's $700/mo Opus pairing per the CLAUDE.md cost matrix).
Search now ends with `RRF -> dedup -> reranker -> token-budget` when
reranker is enabled; fails open to RRF order on any error class
(audit-logged at ~/.gbrain/audit/rerank-failures-*.jsonl).
`KNOBS_HASH_VERSION` bumps 1 -> 2 to fold reranker config into the
query_cache row key. Rolling-deploy operators should expect a temporary
cache hit-rate dip + cache-row doubling for hot queries (clears
naturally within `cache.ttl_seconds`, default 3600s).
Files in this commit are pure docs / version bump:
- VERSION + package.json bump to 0.33.3.0
- CHANGELOG.md release-summary entry with "How to take advantage" block
- CLAUDE.md Key Files annotations for the new recipe + rerank.ts +
rerank-audit.ts + gateway extensions
- docs/ai-providers/zeroentropy.md one-pager (setup, knob reference,
failure observability, troubleshooting table)
- skills/migrations/v0.33.3.md (purely informational: no required user
action; reranker is opt-in everywhere, ZE embedding is opt-in)
- llms-full.txt regenerated to match CLAUDE.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
338 lines
13 KiB
TypeScript
338 lines
13 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.
|
|
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,
|
|
});
|
|
});
|
|
|
|
test('balanced bundle values are canonical', () => {
|
|
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: false,
|
|
reranker_model: 'zeroentropyai:zerank-2',
|
|
reranker_top_n_in: 30,
|
|
reranker_top_n_out: null,
|
|
reranker_timeout_ms: 5000,
|
|
});
|
|
});
|
|
|
|
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,
|
|
});
|
|
});
|
|
|
|
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.
|
|
// CDX2-F14: a timeout change from 5s to 100ms changes search behavior
|
|
// (more fail-opens) so stale cache rows must invalidate.
|
|
expect(KNOBS_HASH_VERSION).toBe(2);
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|