mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* feat(search): per-page max-pool in searchVector (both engines) T1 of the retrieval-cathedral wave (supersedes #1616). Vector search returned chunk-grain top-k with no DISTINCT ON, so a page could be represented by a weak chunk while a hub page's chunks crowded a distinct page's strong chunk out of the candidate set entirely. Keyword search always pooled per page; the vector path did not. - New shared buildBestPerPagePoolCte() in sql-ranking.ts — single source of truth consumed by searchKeyword + searchVector across postgres + pglite, so the two engines can't drift (the recurring parity bug class). - searchVector both engines: compute score as a select-list expr (HNSW ORDER BY stays pure-distance), pool DISTINCT ON (slug) over the full candidate set before the user LIMIT, deterministic tiebreak (slug, score DESC, page_id ASC, chunk_id ASC). - All keyword pooling blocks refactored onto the shared builder (DRY). - Regression test: a hub page's chunks no longer crowd out a distinct page's strong chunk; results are one-per-page by best chunk. Fails on old path. Verified: real-Postgres engine-parity 22/22, PGLite hermetic suite green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(search): title-phrase boost (page.title first-class signal) T2 of the retrieval-cathedral wave. A query that is a phrase from a page's title ("Greek amphitheater" -> "The Mingtang - Indoor Greek Amphitheater") matched a weak body chunk instead of being recognized as a title hit. Names of things deserve weight. - New pure title-match.ts: isTitlePhraseMatch (contiguous token-run inside page.title OR exact full-title match). Precision guards: >= 2 content tokens OR exact full-title; stopword filter; token-boundary match (no raw substring). Reused by the eval later so production + bench can't drift. - applyTitleBoost post-fusion stage in hybrid.ts: reads page.title (not the brittle "first chunk"), floor-ratio-gated, stamps title_match_boost for --explain, never touches base_score (the agent's dedup confidence). - ModeBundle.title_boost knob (1.25, on in all modes - cheap gated correctness fix), search.title_boost config key, dashboard description. - KNOBS_HASH_VERSION 6 -> 7 so a boost-on cache write can't serve a boost-off lookup; all version-pin + canonical-bundle assertions updated. - 18 new tests (matcher 13 + stage 5); typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(search): page_aliases data layer (T3 foundation) Free-text alias resolution for search. gbrain stored a page's chosen names in pages.frontmatter `aliases:` JSONB but search never consulted them, so a query like "Hall of Light" or "明堂" couldn't surface the "Mingtang" page. DELIBERATELY SEPARATE from slug_aliases (re-grounded against current code): - slug_aliases: old-slug -> canonical-slug (wikilink/get_page redirect, populated only from concept-redirect conversions) - page_aliases: normalized free-text name -> canonical slug (search hop) Overloading slug_aliases would muddy two distinct semantics, so this is a new table, not an extension (honors DRY by keeping concepts separate). - src/core/search/alias-normalize.ts: ONE normalizeAlias() (NFKC + lowercase + ws-collapse + quote-strip) + normalizeAliasList() shared by the write (ingest) and read (search) paths so they match on the same key (CQ2). - Migration v108 page_aliases (source_id, alias_norm, slug); btree (source_id, alias_norm) for indexed-equality hop, NOT ILIKE; unique TRIPLE (not source_id+alias_norm) so two pages may claim one alias — collisions reported + resolved at query time, not blocked at ingest (Codex#8). Mirror in pglite-schema.ts; Postgres fresh gets it from the migration. - engine.resolveAliases(aliasNorms, {sourceId|sourceIds}) read + setPageAliases(slug, source, aliasNorms) write, both engines, source-scoped. - 17 tests: normalize round-trip, collision, source-scope, replace, clear. Ingest projection + the hybridSearch alias hop land next (T3 wiring). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(search): alias hop + ingest projection (T3 wiring) Wires the page_aliases data layer into ingest (write) and hybridSearch (read) so a query that is a page's declared chosen name surfaces that page — the named-thing class neither max-pool nor title-boost can fix (true synonyms with zero surface overlap: "Hall of Light" / "明堂" -> the Mingtang page). - Ingest projection (import-file.ts): after the page write commits, normalizeAliasList(frontmatter.aliases) -> engine.setPageAliases. Always called (even []) so removing an alias clears its row; content_hash includes non-timestamp frontmatter so alias edits reach this path, not the skip branch. Fail-soft + pre-v108-safe (isUndefinedTableError swallowed). - applyAliasHop (hybrid.ts), AFTER rerank so a named query reliably surfaces its page: FULL normalized-query exact match only (no substring/n-grams), skip >6-token prose queries, present-boost 1.10x / inject absent canonical at top-of-organic + epsilon (never absolute 1.0, D3), collisions alpha-ordered + capped at 3, fail-open on pre-v108 / lookup error (D9). Stamps alias_hit for the T4 evidence contract. - SearchResult.alias_hit attribution field. - 8 tests: inject/boost/CJK/no-match/long-skip/collision + ingest projection round-trip + alias-removal-clears. 73 pass across the T1/T2/T3 + import suite. Backfill of existing pages' aliases lands as T8 (reindex --aliases). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(search): evidence/create_safety contract + search→cheap-hybrid + per-call mode (T4) The agent-facing fix for the incident's ROOT behavior: tonight the agent read a single blended 0.64 score, decided "no strong match, safe to write a new page", and wrote a duplicate on a developed concept page. A blended RRF/cosine score is not a calibrated probability, so the don't-duplicate decision must key off WHY a page matched, not a raw number. - evidence.ts: classifyEvidence (alias_hit > exact_title_match > high_vector_match > keyword_exact > weak_semantic) + createSafetyFor (exists | probable | unknown). stampEvidence runs at the end of every hybrid return path (main + both keyword fallbacks). SearchResult gains evidence + create_safety. The agent keys don't-duplicate off create_safety='exists', not a score threshold. - search op → cheap-hybrid everywhere (D4/D15): full vector+keyword+RRF+pool+ title+alias, expansion OFF (no per-call LLM cost); `query` stays full-control. search.mcp_keyword_only escape hatch (D17) keeps the old keyword-only behavior for operators who don't want query text sent to an embedding provider. - Alias hop + evidence now also run on the keyword-only fallback paths (the named-thing fix is most valuable exactly when vector is unavailable). - Per-call `mode` (D5): honored ONLY for local/trusted callers (ctx.remote=== false) so a remote OAuth client can't escalate to costly tokenmax; local + unknown mode rejects loudly; threaded into resolveSearchMode + the cache key. - 30 tests (evidence classifier incl. before/after-incident cases, per-call mode gate, alias hop). Updated mcp-eval-capture to the new cheap-hybrid contract. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(cli): reconcile `gbrain search` dispatch (T5) After T4 made the `search` op cheap-hybrid, `gbrain search "x"` already does the right thing — but `gbrain search modes/stats/tune` would have run a hybrid search for the literal word "modes" instead of opening the config dashboard (the op intercepts before the unreachable handleCliOnly dashboard path). Add a pre-dispatch interception in main(): `search` + subArgs[0] in {modes,stats,tune} → runSearch dashboard (with the v0.41.6.0 read-only connect+ dispatch 10s timeout preserved); everything else (free-text) falls through to the cheap-hybrid `search` op. Subprocess test pins all three routes: modes/stats → dashboard, free-text → search op ("No results", not "Unknown subcommand"). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(eval): NamedThingBench retrieval-quality gate (T6) The eval that makes the retrieval-maxpool incident impossible to reintroduce silently. 7 query families, each a failure class the incident exposed: title-substring, generic-to-named, alias-synonym, multi-chunk-dilution, short-vs-rich, graph-relationship, hard-negative. - src/eval/retrieval-quality/harness.ts: pure scoring (Hit@1/Hit@3/MRR per family) + injected SearchFn (CLI uses hybridSearch; tests stub it) + evaluateGate. D12 gate: hard-gate the families that ARE the bug from day one (title-substring Hit@1>=0.95, alias-synonym Hit@1>=0.98, dilution Hit@3=1.0), warn-then-enforce the softer families. Env-overridable floors. - `gbrain eval retrieval-quality <fixture.jsonl> [--json] [--source]` + dispatch in eval.ts. Exit 0 PASS / 1 FAIL / 2 USAGE. - Synthetic fixture (placeholder names only, privacy-grep guarded) + hermetic gate test: seeds a synthetic brain, forces the keyword+title+alias path (embed transport stubbed to throw — free, deterministic), asserts the bug families pass. The vector max-pool guarantee is pinned separately by searchvector-maxpool.test.ts. - CI gate: the hermetic test is a normal unit test, so it runs in every PR shard — the gate is live on every change. - 23 tests (harness unit + hermetic gate + fixture privacy guard). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(telemetry): rank-1 score drift signal (T7) Standing observability so a retrieval regression is caught before a human hits it in chat (like tonight). Aggregate columns on search_telemetry (NOT per-query rows, D10): sum_rank1_score + count_rank1 + 3 coarse buckets (<0.6 / 0.6-0.85 / >=0.85). The mean rank-1 base_score is the headline; a downward drift = retrieval quality regressing. - hybrid.ts: capture rank-1 base_score at all three return paths, thread through emitMeta → recordSearchTelemetry opts (like results_count). - telemetry.ts: Bucket + record + flush ON CONFLICT-add + readSearchStats expose avg_rank1_score (null when no samples — no NaN) + rank1_distribution. - Migration v109 ADD COLUMN IF NOT EXISTS (both engines; search_telemetry lives only in migration v57, so the v57+v109 chain covers fresh + upgrade). Columns exempted in schema-bootstrap-coverage (no forward-ref index → no bootstrap need). - `gbrain search stats` surfaces the avg + bucket line; JSON envelope auto-carries the fields. "true-positive" wording dropped per Codex#14 — production has no labels, so this is an unlabeled rank-1 score histogram; labeled calibration lives in NamedThingBench (T6). - 3 round-trip tests (mean+buckets, no-result excluded, empty=null). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(reindex): gbrain reindex --aliases backfill (T8) Import-time projection (T3) covers new + changed pages; this backfills EXISTING pages whose frontmatter `aliases:` predate v108 / the projection. Walks listAllPageRefs (cheap cross-source (source_id, slug) enumeration), reads each page's frontmatter aliases, writes page_aliases via setPageAliases. Idempotent (setPageAliases replaces) so re-running is convergent — no op-checkpoint needed (fast, no embedding). --dry-run reports would-write counts, --source narrows, --limit caps, --json envelope, progress reporter. Wired into the `reindex` dispatch alongside --markdown / --multimodal. 4 tests: backfill from array + comma-scalar frontmatter, --dry-run writes nothing, idempotent second run. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(search): pre-migration fail-open regression (T9) Pins that pre-v108 brains (no page_aliases table) keep working: applyAliasHop returns input unchanged + doesn't throw, importFromContent with frontmatter aliases still imports (projection swallows table-missing via isUndefinedTableError), and resolveAliases surfaces the error for the caller to catch. Completes the T9 mandatory regression set (dilution → searchvector-maxpool, dispatch → cli-search-dispatch, MCP contract → mcp-eval-capture, engine parity → engine-parity 22/22, pre-migration → here). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(search): Phase-0 retrieval diagnostic — `gbrain search diagnose` (T0) The operator-facing trace the user runs against the production brain to pin which retrieval layer surfaces (or misses) a target page — the diagnostic the plan front-loaded so we don't ship a fix that doesn't move the incident. `gbrain search diagnose "<query>" --target <slug> [--json] [--source]` reports, for the target: keyword rank+score, vector rank+score (skipped/graceful if no embedding provider), whether the query is a registered alias, and the hybrid final rank + evidence + create_safety + which boosts fired (title/alias). The verdict names the layer that surfaces the target at rank 1 (or "none"), telling you whether the lever is max-pool/innerLimit (vector) vs title/alias. Wired into the `search` dispatch alongside modes/stats/tune (60s timeout since it runs real retrieval). 2 hermetic tests (alias-query trace + title-phrase trace). For the Mingtang incident, run: gbrain search diagnose "Greek amphitheater" --target projects/new-greek-theater/concept_v0 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(retrieval): corrected incident record + named-thing layers + glossary (T10) - RETRIEVAL_MAXPOOL_INCIDENT.md: replaces closed PR #1616's RFC with the verified record — what happened, the disease, the corrections to the RFC's mechanics (search was keyword-only, --mode unthreaded, hybrid already pooled at dedup, aliases dead to search), the four-layer fix that shipped, and the triage commands (search diagnose / reindex --aliases / search stats / eval retrieval-quality). - RETRIEVAL.md: new "Named-thing retrieval" section documenting per-page pool + title boost + alias hop + the evidence contract, reconciling the doc with the shipped pipeline (closes the doc/reality gap). - metric-glossary.ts + regenerated METRIC_GLOSSARY.md: Hit@1, Hit@3, avg_rank1_score (drift signal, not labeled accuracy), and create_safety (the evidence contract) now carry plain-English glossary entries. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(eval): NamedThingBench fixture privacy guard via slug-shape (T6 fixup) The banned-name literal list itself tripped check-privacy/check-test-real-names. Replace it with the load-bearing assertion: every fixture slug must be an *-example placeholder (no real brain page can be referenced). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(search): source-isolation in per-page pool + alias hop (P0, codex adversarial) Codex outside-voice caught two source-isolation P0s in the retrieval wave — the exact class the v0.34.1 seal guards. Both fixed before merge. P0-1: buildBestPerPagePoolCte pooled on `slug` alone. In a federated brain, two pages with the same slug in different sources collapsed before ranking/pagination (the neighbor-source page dropped). Now DISTINCT ON (COALESCE(source_id,'default'), slug) — composite key matching dedup.ts's pageKey. Also fixes the PRE-EXISTING keyword-path bug (best_per_page was slug-only before this wave); real-PG parity 23/23. P0-2: the alias hop dropped source_id. resolveAliases returned bare slugs and applyAliasHop hydrated via getPage(slug, undefined), so a federated caller could get the default-source page injected or the right allowed-source page suppressed. resolveAliases now returns {slug, source_id} pairs; applyAliasHop matches by (source_id, slug) and fetches each canonical in its OWN source. Regression tests: alias hop boosts only the aliased source (not same-slug in another source); resolveAliases keeps cross-source same-slug distinct. Deferred as documented tradeoffs (TODO): evidence high_vector_match label uses blended base_score not pure cosine; deep-pagination candidate budget is chunk-bounded; telemetry writes swallow errors pre-v109 on rolling deploys. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.41.30.0) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: v0.41.30.0 retrieval cathedral — CLAUDE.md key files + llms regen Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: renumber release v0.41.30.0 → v0.41.34.0 (queue moved) Version trio + CHANGELOG header + CLAUDE.md key-file annotations + TODOS heading + regenerated llms bundles, all moved to 0.41.34.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ci): restore glossary roster + harden facts-anti-loop hook budget Two CI failures surfaced after the master merges that brought the branch to 111 migrations: 1. shard 1 — `ALL_METRICS roster > matches the renderer output (no orphans)`: the merge took master's `renderMetricGlossaryMarkdown` whose `groups` array lacked this branch's 4 retrieval-quality keys (hit@1, hit@3, avg_rank1_score, create_safety). `ALL_METRICS` (derived via Object.keys) kept them, so the roster test saw 4 orphans. The freshness check (check:eval-glossary) passed because renderer-output == committed doc — it can't catch a renderer that drops a metric; the roster test can. Restored the "Retrieval-Quality / Evidence Metrics (NamedThingBench)" group + regenerated docs/eval/METRIC_GLOSSARY.md. 2. shard 2 — facts-anti-loop's two engine-dependent put_page tests failed while the two engine-free extractFactsFromTurn tests passed (the signature of a partially-failed beforeAll). This file has a documented PGLite-cold-start-under-deep-shard-load timeout history; the 30s budget was tuned for 95 migrations and the chain is now 111. Bumped to 60s. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ci): isolate facts-anti-loop in its own process (serial) Follow-up to the prior hook-timeout bump, which was the wrong theory: the [58ms]/[71ms] body times in the re-run prove beforeAll did NOT time out — the engine connects and the two put_page tests run and fail for real, while the two engine-free extractFactsFromTurn tests in the same file pass. put_page (via dispatchToolCall) touches process-global singletons (the facts queue + the AI gateway used by importFromContent's embed step). Some sibling file in the 78-file shard-2 process leaves residual global state that makes put_page's pre-backstop path fail on the CI runner. The failure is NOT reproducible alone, in a Linux oven/bun:1 container, or in a full local shard-2 run (1172 pass) — only on the GitHub runner, deterministically. Per CLAUDE.md's test-isolation rules, a test coupled to shared process state belongs in its own process. Renamed to *.serial.test.ts so it runs in the dedicated serial-tests job (scripts/run-serial-tests.sh spawns a fresh `bun test` per serial file), where it passes deterministically; test-shard.sh excludes serial files from the matrix. Updated the comment to reflect the real cause and refreshed the test-weights.json key. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ci): close cross-file gateway-config pollution in test shards The prior serial-move theory was incomplete. The real, single root cause behind all three shard failures (2, 5, 10) is cross-file AI-gateway config pollution within a shard's bun process: - A test calls configureGateway() and doesn't restore the gateway on exit. The legacy-embedding preload pins OpenAI/1536 ONCE at process start and re-pins per-test ONLY when the gateway slot is empty — so a leaker that reconfigured the gateway to the v0.37 default (zeroentropyai:zembed-1 / 1280-d) and never reset poisons every later file in the shard. - Victim A (shard 5, test/search/searchvector-maxpool.test.ts): runs initSchema in beforeAll under the leaked gateway → content_chunks.embedding becomes vector(1280) → inserting its hardcoded 1536-d basis vectors throws pgvector CheckExpectedDim. - Victims B/C (shard 10 facts-backstop-gating, shard 2 facts-anti-loop): put_page's importFromContent embeds by design (embed failure PROPAGATES, Codex C2). Under a leaked fake-key gateway the embed step 401s and put_page returns isError → the backstop assertions fail. My branch's shard re-partition (added test files + weight changes) merely co-located leakers with victims; the hazard was latent. Fixes (root cause + self-sufficient victims): - test/search/rerank.test.ts (the shard-5 leaker): add afterAll(resetGateway). Its stub omits embedding_model, so it fell back to the ZE/1280 default; now it restores the empty slot so the preload re-pins legacy for the next file. - test/search/searchvector-maxpool.test.ts: pin configureGateway(openai/1536) in beforeAll BEFORE initSchema (initSchema runs before any preload beforeEach, so it can't rely on the inherited slot). - test/facts-backstop-gating.test.ts + test/facts-anti-loop.test.ts: reset the gateway in beforeEach so put_page's embed is a graceful no-op; reverted anti-loop from the serial quarantine back into the matrix (the serial move was the wrong fix for a gateway-state problem). Validated deterministically: a non-resetting leaker that poisons the gateway to ZE, run first in one bun process, no longer breaks any of the three victims (14/14 pass). verify 29/29, typecheck clean, isolation lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
545 lines
22 KiB
TypeScript
545 lines
22 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,
|
|
};
|
|
|
|
// v0.40.3.0 contextual retrieval per-mode defaults. Tests below spread
|
|
// this AFTER CROSS_MODAL_DEFAULTS so each per-mode block overrides
|
|
// contextual_retrieval to its tier value.
|
|
const CR_DISABLED_DEFAULT = { contextual_retrieval_disabled: 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,
|
|
title_boost: 1.25,
|
|
...CROSS_MODAL_DEFAULTS,
|
|
graph_signals: false,
|
|
...CR_DISABLED_DEFAULT,
|
|
contextual_retrieval: 'none',
|
|
});
|
|
});
|
|
|
|
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,
|
|
title_boost: 1.25,
|
|
...CROSS_MODAL_DEFAULTS,
|
|
graph_signals: true,
|
|
...CR_DISABLED_DEFAULT,
|
|
contextual_retrieval: 'title',
|
|
});
|
|
});
|
|
|
|
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,
|
|
title_boost: 1.25,
|
|
...CROSS_MODAL_DEFAULTS,
|
|
graph_signals: true,
|
|
...CR_DISABLED_DEFAULT,
|
|
contextual_retrieval: 'per_chunk_synopsis',
|
|
});
|
|
});
|
|
|
|
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('v0.40.6.1 — reranker_timeout_ms threads recipe default through resolution', () => {
|
|
// The dead-default-timeout-ms class of bugs: hybridSearch always passes
|
|
// resolvedMode.reranker_timeout_ms to gateway.rerank(). Pre-v0.40.6.1 the
|
|
// mode bundle's 5000ms hardcoded value always won, so recipe-level
|
|
// default_timeout_ms was dead. These tests pin the new precedence chain:
|
|
// per-call > config override > recipe touchpoint default > bundle.
|
|
|
|
test('llama-server-reranker resolves to 30000ms recipe default (no override)', () => {
|
|
const r = resolveSearchMode({
|
|
mode: 'balanced',
|
|
overrides: { reranker_model: 'llama-server-reranker:qwen3-reranker-4b' },
|
|
});
|
|
expect(r.reranker_model).toBe('llama-server-reranker:qwen3-reranker-4b');
|
|
expect(r.reranker_timeout_ms).toBe(30_000);
|
|
});
|
|
|
|
test('config override beats recipe default', () => {
|
|
const r = resolveSearchMode({
|
|
mode: 'balanced',
|
|
overrides: {
|
|
reranker_model: 'llama-server-reranker:qwen3-reranker-4b',
|
|
reranker_timeout_ms: 90_000,
|
|
},
|
|
});
|
|
expect(r.reranker_timeout_ms).toBe(90_000);
|
|
});
|
|
|
|
test('per-call override beats config override AND recipe default', () => {
|
|
const r = resolveSearchMode({
|
|
mode: 'balanced',
|
|
overrides: {
|
|
reranker_model: 'llama-server-reranker:qwen3-reranker-4b',
|
|
reranker_timeout_ms: 90_000,
|
|
},
|
|
perCall: { reranker_timeout_ms: 100 },
|
|
});
|
|
expect(r.reranker_timeout_ms).toBe(100);
|
|
});
|
|
|
|
test('ZE (no recipe default) regression: still gets bundle default of 5000ms', () => {
|
|
// ZeroEntropy's recipe does not declare default_timeout_ms — its hosted
|
|
// path is fast enough that the bundle default suffices.
|
|
const r = resolveSearchMode({
|
|
mode: 'balanced',
|
|
overrides: { reranker_model: 'zeroentropyai:zerank-2' },
|
|
});
|
|
expect(r.reranker_timeout_ms).toBe(5000);
|
|
});
|
|
|
|
test('unknown provider id falls through to bundle default', () => {
|
|
const r = resolveSearchMode({
|
|
mode: 'balanced',
|
|
overrides: { reranker_model: 'made-up-provider:fake-model' },
|
|
});
|
|
expect(r.reranker_timeout_ms).toBe(5000);
|
|
});
|
|
});
|
|
|
|
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`.
|
|
// v0.40.4 (salem) + v0.39 T21 (master): bumped 3→4 to fold graph_signals
|
|
// (so a graph-on cache write cannot be served to a graph-off lookup) AND
|
|
// schema-pack hash fields (pack name + pack version, so cross-pack
|
|
// contamination is structurally impossible).
|
|
// v0.40.3.0 (D8): bumped 4→5 to add contextual_retrieval (CRMode) and
|
|
// contextual_retrieval_disabled (kill switch). A query against a brain
|
|
// on tokenmax (per-chunk synopsis) must not be served from a cache row
|
|
// written when the brain was on balanced (title-only) — different
|
|
// embedding spaces. Sequenced behind salem's v=4 graph-signals work.
|
|
// v0.41.22.0 (type-unification): bumped 5→6 for the new alias_resolved
|
|
// post-fusion boost stage. A query against a brain with slug_aliases
|
|
// populated must not be served from a cache row written before the
|
|
// boost stage existed.
|
|
expect(KNOBS_HASH_VERSION).toBe(7);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
describe('v0.40.4 — graph_signals knob', () => {
|
|
test('default per mode: conservative=false, balanced=true, tokenmax=true', () => {
|
|
expect(MODE_BUNDLES.conservative.graph_signals).toBe(false);
|
|
expect(MODE_BUNDLES.balanced.graph_signals).toBe(true);
|
|
expect(MODE_BUNDLES.tokenmax.graph_signals).toBe(true);
|
|
});
|
|
|
|
test('config key search.graph_signals overrides bundle (true → false)', () => {
|
|
const ov = loadOverridesFromConfig({ 'search.graph_signals': 'false' });
|
|
expect(ov.graph_signals).toBe(false);
|
|
const resolved = resolveSearchMode({ mode: 'balanced', overrides: ov });
|
|
expect(resolved.graph_signals).toBe(false);
|
|
});
|
|
|
|
test('config key search.graph_signals overrides bundle (false → true)', () => {
|
|
const ov = loadOverridesFromConfig({ 'search.graph_signals': '1' });
|
|
expect(ov.graph_signals).toBe(true);
|
|
const resolved = resolveSearchMode({ mode: 'conservative', overrides: ov });
|
|
expect(resolved.graph_signals).toBe(true);
|
|
});
|
|
|
|
test('per-call overrides config + mode bundle', () => {
|
|
const resolved = resolveSearchMode({
|
|
mode: 'balanced',
|
|
overrides: { graph_signals: false },
|
|
perCall: { graph_signals: true },
|
|
});
|
|
expect(resolved.graph_signals).toBe(true);
|
|
});
|
|
|
|
test('knobsHash distinct for graph_signals=true vs =false', () => {
|
|
const on = knobsHash(resolveSearchMode({ mode: 'balanced', perCall: { graph_signals: true } }));
|
|
const off = knobsHash(resolveSearchMode({ mode: 'balanced', perCall: { graph_signals: false } }));
|
|
expect(on).not.toBe(off);
|
|
});
|
|
|
|
test('SEARCH_MODE_CONFIG_KEYS includes search.graph_signals', () => {
|
|
expect(SEARCH_MODE_CONFIG_KEYS).toContain('search.graph_signals');
|
|
});
|
|
|
|
test('attributeKnob reports source correctly for graph_signals', () => {
|
|
const input = { mode: 'balanced', perCall: { graph_signals: false } };
|
|
const resolved = resolveSearchMode(input);
|
|
const attr = attributeKnob('graph_signals', input, resolved);
|
|
expect(attr.source).toBe('per-call');
|
|
expect(attr.value).toBe(false);
|
|
});
|
|
|
|
test('attributeKnob mode source when no override', () => {
|
|
const input = { mode: 'tokenmax' };
|
|
const resolved = resolveSearchMode(input);
|
|
const attr = attributeKnob('graph_signals', input, resolved);
|
|
expect(attr.source).toBe('mode');
|
|
expect(attr.value).toBe(true);
|
|
});
|
|
});
|