v0.41.33.0 feat(search): intent-aware adaptive return-sizing + agent-facing query param (#1640)

* feat(search): intent-aware adaptive return-sizing (default-off)

New opt-in retrieval feature: trim the ranked result set to an intent-driven cap (entity -> tight, else -> recall-preserving) instead of always returning top-K. Pure module src/core/search/return-policy.ts (resolve + apply + config-read + at-least-minKeep failsafe); wired into hybridSearch after rerank, before slice, offset===0 only; decision stamped into HybridSearchMeta.adaptive_return; cache skipped when on (KNOBS_HASH fold is a follow-up). SearchOpts.adaptiveReturn per-call override. Default OFF — existing search behavior byte-identical. Mechanism is an intent cap, not a score-cliff detector (PrecisionMemBench data showed the cliff carries no signal). 19 unit tests.

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: document adaptive return-sizing module in CLAUDE.md (v0.41.30.0)

Add a Key Files entry for src/core/search/return-policy.ts (intent-aware
adaptive return-sizing, default OFF) covering its exports, the four
search.adaptive_return* config knobs, the hybrid.ts wiring (post-rerank,
pre-slice, offset===0 only), and the cache-skip behavior. Regenerate
llms-full.txt to match.

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

* feat(query): agent-facing adaptive_return param on the query op

Expose adaptive_return (boolean) on the query MCP/CLI op so the AGENT — not the human config knob — decides per query whether to return a tight, intent-sized set. The param description teaches WHEN (single-answer questions on; breadth off; limit:1 for a hard single-answer cap), matching the salience/recency 'YOU (the agent) decide' pattern. Threaded into hybridSearchCached. End users never touch config; their agent serves them per query. Pinned by test/search/query-op-adaptive-return.test.ts.

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

* chore: re-version to v0.41.33.0 + document agent surface

Re-version 0.41.30.0 -> 0.41.33.0 (VERSION/package.json/CHANGELOG/TODOS/CLAUDE.md). CHANGELOG + CLAUDE.md now document the agent-facing query-op adaptive_return param + when-to-use guidance. llms regenerated (build-llms test green).

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

* fix(core): harden fence-strip + config parsing against non-string input

stripTakesFence/stripFactsFence crashed with "undefined is not an object"
when a read op returned a page with no compiled_truth (e.g. metadata-only
rows). The get_page untrusted-reader path calls both on page.compiled_truth,
which can be undefined. Guard both to no-op when body is not a string.

loadSearchModeConfig.safeGet trusted engine.getConfig to honor its
string|null contract; a non-string value (array/boolean) reached
loadOverridesFromConfig and crashed on ce.toLowerCase(). Treat any
non-string config value as "not set" so it falls through to the
mode-bundle default, matching missing-key behavior.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-30 11:21:56 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent f79c1306a2
commit 730aed77f2
15 changed files with 498 additions and 5 deletions
+118
View File
@@ -2,6 +2,124 @@
All notable changes to GBrain will be documented in this file.
## [0.41.33.0] - 2026-05-29
**`gbrain search` and `query` can now return a tight, intent-sized set of
results instead of always handing back the full top-K pile. Opt-in, off by
default, so nothing changes until you turn it on — and your agent can now flip
it per query via a new `adaptive_return` flag on the `query` tool.**
Most retrieval hands back the top 10-50 hits and lets you (or a downstream
model) sort the wheat from the chaff. That is the right default for "show me
everything related," but it is noisy when you asked a question that has one
answer. The new `search.adaptive_return` knob makes retrieval return roughly
as many results as the question deserves: a single-answer lookup gets a tight
set, an enumeration query keeps the comparable cluster. Less noise for you
reading results, a cleaner candidate set for any agent or `think` loop that
consumes them, and lower downstream token spend.
It is **off by default** and changes nothing until enabled. Turn it on:
```bash
gbrain config set search.adaptive_return true
gbrain config set search.adaptive_return_entity_max 2 # cap for lookup-style queries
gbrain config set search.adaptive_return_other_max 6 # cap for enumeration queries
```
The config knob is the human, set-and-forget path. **The agent path is the
point:** the `query` MCP tool now takes an `adaptive_return` boolean, and its
description tells the agent exactly when to use it — set it TRUE when the user
asked something with a small, specific answer (a lookup, a single-fact recall,
anything routed into a precise downstream step) so the user gets the answer
instead of a wall of pages; leave it off for breadth ("everything about X",
"list all", exploration) where recall matters more. The end user never touches a
config knob — their agent decides per query, the same way it already decides
`salience` and `recency`. Pass `limit: 1` alongside it for a hard single-answer
cap. Library callers can also pass `SearchOpts.adaptiveReturn` (`true`, or
`{ entityMax, otherMax, minKeep }`); defaults are 2 / 6 (recall-preserving).
A few things we were careful about:
- **It never hands back an empty result when there are candidates.** A human
searching always gets at least the top hit, no matter how tight the caps.
- **It only fires on the first page** (`offset` 0). Paginating a
confidence-trimmed set is incoherent, so paged calls fall back to the fixed
limit.
- **Recall is the tradeoff.** Returning fewer results can drop a legitimate
second or third answer, which is exactly why this ships off by default and
why the recall-preserving caps are the default when you do turn it on.
- **The query cache is skipped while it is on** (a trimmed result set must not
be served to a gate-off lookup). Folding the gate into the cache key is a
follow-up; until then, adaptive-on calls always retrieve fresh.
This came out of running gbrain against PrecisionMemBench (a precision-only
memory benchmark) in the gbrain-evals repo. gbrain's default top-K retrieval
scores low on raw precision there, as any top-K system does. With this feature
on, gbrain lands second on that benchmark behind a purpose-built belief store.
The measurement also killed a fancier idea: a "score cliff" detector turned out
to carry no signal (the score gap after the top hit is the same whether the top
hit is right or wrong), so the mechanism is a simple intent-driven cap, not a
cliff cut.
### To take advantage of v0.41.33.0
`gbrain upgrade` handles this. There is no schema migration and no manual step.
The feature is dormant until used.
0. **Agents:** nothing to configure — the `query` tool's `adaptive_return`
param is live, and its description tells you when to set it. Reach for it on
single-answer questions; skip it on broad / exploratory ones.
1. **Humans (set-and-forget, per-brain):**
```bash
gbrain config set search.adaptive_return true
```
2. **Tune the caps** to taste, tighter for precision, looser for recall:
```bash
gbrain config set search.adaptive_return_entity_max 2
gbrain config set search.adaptive_return_other_max 6
```
3. **Inspect what it did** on any query with `--json`: the `adaptive_return`
field in the search meta shows the intent, the cap, and how many results
were kept versus the full candidate count.
4. **If results feel too thin,** raise the caps or set
`search.adaptive_return false` to return to the previous top-K behavior. No
data change either way.
### Itemized changes
**Adaptive return-sizing (new feature, default-off)**
- `src/core/search/return-policy.ts` (new): pure module. `resolveAdaptiveReturn`
(defaults then config plane then per-call precedence), `applyAdaptiveReturn`
(intent-driven cap with an at-least-`minKeep` failsafe so it never empties a
non-empty result set), `adaptiveReturnFromConfig` (reads `search.adaptive_*`),
`adaptiveReturnEnabled`, and `DEFAULT_ADAPTIVE_RETURN` (`enabled:false`,
`entityMax:2`, `otherMax:6`, `minKeep:1`). The mechanism is an intent-driven
cap, not a score-cliff detector (the cliff carries no signal).
- `src/core/search/hybrid.ts`: applies the gate after the reranker and before
the limit slice, only when enabled and `offset===0`, reading the
ordering-driving score. Stamps the decision into
`HybridSearchMeta.adaptive_return`. `hybridSearchCached` skips the cache when
the gate is on so a trimmed set is never served to a gate-off lookup
(KNOBS_HASH fold is a documented follow-up).
- `src/core/types.ts`: `SearchOpts.adaptiveReturn` (`boolean` or partial config)
and `HybridSearchMeta.adaptive_return` (intent / cap / kept / total) for
`--json` and `--explain` consumers.
- `src/core/operations.ts`: the **agent-facing surface**`query` op gains an
`adaptive_return` boolean param whose description instructs the agent when to
use it (single-answer → on; breadth → off), matching the `salience`/`recency`
"YOU (the agent) decide" pattern. Threaded into `hybridSearchCached`.
**Tests**
- `test/search/return-policy.test.ts` (19 cases): config-resolution precedence,
intent-to-cap mapping, the never-empty failsafe, `minKeep > cap` recall floor,
and the default-off passthrough contract.
- `test/search/query-op-adaptive-return.test.ts` (3 cases): pins the agent
surface — the `query` op exposes `adaptive_return` and its description teaches
both directions of the decision plus the never-empty safety contract.
Measured against PrecisionMemBench in the sibling gbrain-evals repo (faithful
vendored scorer); the default-off path is byte-identical to prior behavior (the
existing search suite passes unchanged).
## [0.41.32.0] - 2026-05-30
**A quiet repo that's fully caught up no longer screams `SEVERELY STALE` in
+1
View File
@@ -123,6 +123,7 @@ strict behavior when unset.
- `src/core/rerank-audit.ts` (v0.35.0.0) — failure-only JSONL audit at `~/.gbrain/audit/rerank-failures-YYYY-Www.jsonl` (ISO-week rotation, mirrors `src/core/audit-slug-fallback.ts`). Exports `logRerankFailure({reason, model, query_hash, doc_count, error_summary})` + `readRecentRerankFailures(days)`. **Deliberately no `logRerankSuccess`** (CDX2-F22 in plan review): writing once per tokenmax search is hot-path I/O churn AND success events leak query volume + timing into a local audit file. `gbrain doctor`'s `reranker_health` check reads `search.reranker.enabled` first so "no events in window" is interpreted correctly (disabled → ok; enabled → ok). Query text is SHA-256-prefix-hashed (8 hex chars) for privacy. `GBRAIN_AUDIT_DIR` env override honored via the shared `resolveAuditDir()`.
- `src/core/search/embedding-column.ts` (v0.36.3.0) — single source of truth for "which `content_chunks.*` column does this query rank against?" Pure functions, no engine I/O: `loadRegistry(cfg)` walks the `embedding_columns` config (DB plane, JSON map keyed by column name with `{provider, dimensions, type}` entries), seeds the OpenAI `embedding` builtin when unset, validates everything before it lands (column-name regex, type ∈ `vector | halfvec`, dims in [1, 8192], provider format) using `Object.create(null)` + `Object.hasOwn` so a key like `constructor` rejects instead of resolving to `Object.prototype.constructor`. `resolveColumn(registry, override?, cfg)` is the boundary call: returns a `ResolvedColumn` descriptor (frozen `{name, provider, dimensions, type}`) honoring per-call override → `search_embedding_column` config → `'embedding'` default. Throws `UnknownEmbeddingColumnError` with the list of registered names on miss. `isCacheSafe(resolved, cfg)` compares the full embedding SPACE (provider + dimensions + name) against cfg's default — a user who repointed the `embedding` builtin at Voyage doesn't accidentally serve OpenAI-shaped cache rows. `validateResolvedColumn(descriptor)` re-validates hand-rolled descriptors that bypass the registry (internal-SDK passthrough path) so the SQL-injection escape hatch through the descriptor field is closed. Consumed by `hybridSearch` (resolves once at the boundary, threads a `ResolvedColumn` into per-engine `searchVector` instead of a raw string), `gateway.embedQuery(text, {embeddingModel, dimensions})` (resolved column's provider drives the query-time embed call), `cosineReScore` (engine pulls vectors from the active column, not the hardcoded `embedding`), and the `query` MCP op (per-call `embedding_column` param). 538 lines of pure resolver, 511 unit cases in `test/search/embedding-column.test.ts` covering the 16 codex-flagged corners (prototype-pollution, descriptor passthrough, env-only Postgres install, empty-brain coverage gate, cache-space comparison).
- `src/core/search/rerank.ts` (v0.35.0.0) — the call-site abstraction. `applyReranker(query, results, opts)` slots between `dedupResults()` and `enforceTokenBudget()` in `src/core/search/hybrid.ts`. Slices `opts.topNIn` (default 30) by current RRF order, sends to `gateway.rerank()`, reorders by `relevanceScore` desc, and appends the un-reranked tail unchanged (recall protection). **Fail-open on every `RerankError.reason`**: any error logs via `logRerankFailure` and returns the input array unchanged. Stamps `rerank_score` onto reordered items so downstream telemetry sees the new ordering signal. `topNOut: null` is the explicit "don't truncate" signal — semantically distinct from `undefined` which means "fall through to mode bundle" (CDX2-F16). Test seam: `opts.rerankerFn` lets tests stub `gateway.rerank` without touching the network.
- `src/core/search/return-policy.ts` (v0.41.33.0, NEW — default OFF) — intent-aware adaptive return-sizing. Pure, dependency-light module that trims the final ranked candidate set to an intent-driven cap instead of always returning the full top-K (noisy) list. `entity` intent (single-answer-ish) gets a tight cap; `temporal`/`event`/`general` (enumeration-ish) get a recall-preserving cap. A `minKeep` failsafe (≥1) guarantees a human never gets a silent blank when candidates exist. WHY a cap, not a score-cliff detector: PrecisionMemBench Phase-1 instrumentation (gbrain-evals) measured that the rank1→rank2 RRF score gap is ~identical whether rank-1 is correct (0.602) or wrong (0.569) — mechanical decay, not a trustworthy separatrix; rank-1 is right in 94% of single-answer cases, so "return a tight set" is the whole win and cliff-cutting just adds noise. Exports `AdaptiveReturnConfig`, `DEFAULT_ADAPTIVE_RETURN` (frozen: `enabled=false`, `entityMax=2`, `otherMax=6`, `minKeep=1`), `AdaptiveReturnDecision` (`{applied, intent, cap, kept, total}`), `AdaptiveReturnInput` (`boolean | Partial<AdaptiveReturnConfig> | undefined`), `adaptiveReturnFromConfig(cfg)` (reads the config plane), `resolveAdaptiveReturn(perCall, fromConfig)` (defaults → config → per-call merge), `adaptiveReturnEnabled(...)` (gate check for the cache skip), and `applyAdaptiveReturn(results, intent, cfg)` (the trim). Config knobs (DB or file plane): `search.adaptive_return` (master switch, boolean), `search.adaptive_return_entity_max`, `search.adaptive_return_other_max`, `search.adaptive_return_min_keep` (each clamped to ≥1). Precision-sensitive agents tune entity=1, other=1 ≈ "return only the top result." Wired into `hybridSearch` (`src/core/search/hybrid.ts`): applied AFTER `applyReranker`, BEFORE the `limit` slice, and ONLY on the first page (`offset===0`) — paginating a confidence-gated set is incoherent, so paginated calls fall through to the fixed limit. Stamps the decision onto `HybridSearchMeta.adaptive_return` for `gbrain search --explain`. `hybridSearchCached` SKIPS the cache when the gate is on (a trimmed set must not be served to a gate-off lookup and vice versa); folding the gate params into `KNOBS_HASH` so adaptive-on calls cache safely is a v0.42+ follow-up before any mode-default flip. `SearchOpts.adaptiveReturn` + `HybridSearchMeta.adaptive_return` declared in `src/core/types.ts`. **Agent-facing surface (v0.41.33.0):** the `query` op (`src/core/operations.ts`) exposes an `adaptive_return` boolean param whose description instructs the agent WHEN to set it (single-answer questions → on; breadth/exploration → off; pass `limit:1` for a hard single-answer cap), threaded into `hybridSearchCached`. This is the path that matters — end users never touch the config knob; their agent decides per query (same pattern as `salience`/`recency`). Pinned by `test/search/return-policy.test.ts` (mechanism) + `test/search/query-op-adaptive-return.test.ts` (agent surface: param exists + description teaches both directions + the never-empty contract).
- `src/core/ai/recipes/voyage.ts` — Voyage AI openai-compatible recipe. **v0.28.7 (#680):** declares `chars_per_token=1` + `safety_factor=0.5` so the gateway pre-splits Voyage batches at a 60K-character budget (50% of 120K-token cap with the dense-tokenizer ratio). Closes the v0.27 backfill loop where ~26% of the corpus stayed un-embedded because tiktoken-grounded budgeting silently undercounted Voyage's actual token usage. **v0.28.11 (#719):** declares `multimodal_models: ['voyage-multimodal-3']` so the gateway rejects text-only Voyage models pointed at the multimodal endpoint with a clear `AIConfigError` instead of waiting for Voyage's HTTP 400. **v0.33.1.1 (#962, fixup):** recipe docstring at `:7-16` tightened to name the seven hosted flexible-dim models that accept `output_dimension` explicitly (`voyage-4-large`, `voyage-4`, `voyage-4-lite`, `voyage-3-large`, `voyage-3.5`, `voyage-3.5-lite`, `voyage-code-3`) and call out that `voyage-4-nano` is the open-weight variant listed separately by Voyage as fixed 1024-dim — does NOT accept the parameter. The "all v4 variants are flexible" misread is what caused the original PR to include nano in `VOYAGE_OUTPUT_DIMENSION_MODELS`; the negative regression assertion in `test/ai/gateway.test.ts` (`dimsProviderOptions` returns `undefined` for `voyage-4-nano`) pins the contract. **v0.37.3.0:** `voyage-code-3` is the recommended embedding model for gstack per-worktree code brains (Topology 3 in `docs/architecture/topologies.md`). Registration was already in the `models` list since pre-v0.33; the v0.37.3.0 wave adds discoverability surfaces — decision-tree branch in `docs/integrations/embedding-providers.md`, Topology 3 "Recommended embedding model" subsection, runtime nudge from `gbrain reindex --code` against non-code-tuned models. Recipe-shape regression pinned by `test/ai/voyage-code-3-recipe.test.ts`.
- `src/core/ai/recipes/anthropic.ts` — Anthropic recipe (chat + expansion touchpoints). **v0.31.12:** chat and expansion `models:` lists drop the v0.31.6 phantom `claude-sonnet-4-6-20250929` date suffix — canonical id is `claude-sonnet-4-6`. The wrong-direction alias `claude-sonnet-4-6 → claude-sonnet-4-6-20250929` is removed; a reverse alias `claude-sonnet-4-6-20250929 → claude-sonnet-4-6` keeps stale user configs working (rescues `facts.extraction_model` and `models.dream.synthesize` set by v0.31.6 installs). Recipe-shape regression pinned by `test/anthropic-model-ids.test.ts` (6 cases, verbatim cherry-pick of PR #830 plus the reverse-alias rescue case).
- `src/core/anthropic-pricing.ts` — Single source of truth for Anthropic model pricing (per-MTok input/output). **v0.31.12:** Opus 4.7 corrected from `$15/$75` to `$5/$25` (the old number was from Opus 4 generation, never refreshed when 4.7 shipped); Opus 4.6 also corrected. Consumed by `src/core/budget-meter.ts` and `src/core/cross-modal-eval/runner.ts` — the cross-modal estimator now reads `ANTHROPIC_PRICING` for Anthropic models instead of duplicating the table, killing the v0.31.6 drift bug class.
+11
View File
@@ -1,5 +1,16 @@
# TODOS
## v0.41.33.0 adaptive return-sizing follow-ups (v0.42+)
Filed from the v0.41.33.0 wave (intent-aware adaptive return-sizing, born from
the PrecisionMemBench integration in gbrain-evals). The feature shipped
default-off; these are the gates and extensions before any default flip.
- [ ] **v0.42+: cross-surface ablation before flipping `search.adaptive_return` default.** The gate ships default-off. Before turning it on in any `MODE_BUNDLES` tier, run the recall ablation (adaptive off vs on, recall-preserving caps) across `gbrain eval longmemeval`, `gbrain eval whoknows`, `gbrain eval suspected-contradictions`, and the BrainBench-Real replay (sibling gbrain-evals repo). Confirm recall@k / answer quality does not regress; pick the safe caps; probably flip `tokenmax` first (broadest searchLimit, most noise). On-surface evidence (the PrecisionMemBench precision/recall frontier: off 0.076/0.99, e1/o2 0.40/0.91, e1/o1 0.58/0.82) is recorded in `gbrain-evals/docs/benchmarks/2026-05-29-precisionmembench.md`. Priority: P2.
- [ ] **v0.42+: fold adaptive-return params into KNOBS_HASH so adaptive-on calls can cache.** v0.41.33.0 skips `hybridSearchCached` entirely when the gate is on (cache-safe but cache-cold). Fold `adaptive_return` enabled + caps + `minKeep` into `knobsHash()` (append-only, bump `KNOBS_HASH_VERSION`) so a gate-on write segregates from a gate-off row and adaptive calls cache correctly. Required before any default flip (else default-on means cache-cold everywhere). See `src/core/search/mode.ts` KNOBS_HASH parts + `return-policy.ts`. Priority: P2 (paired with the default-flip ablation above).
- [ ] **v0.42+: gentle adaptive gate on `think`'s gather stage (A3).** The plan's A3 decision was a gentler return-gate on `runThink`'s gather candidates (cleaner context, fewer tokens per reasoning call). Deferred because the benefit is unvalidated without a longmemeval answer-quality run, and trimming the answer path (even default-off) carries regression risk. gather fuses 4 streams (page / takes-keyword / takes-vector / graph); the gate must operate on the fused output with a higher min-keep than search, validated on `gbrain eval longmemeval` answer quality (not retrieval precision). Also: `RunThinkOpts` has no `sourceId` today, so think's gather runs unscoped (codex finding) — scope-isolated think needs that plumbing first. Priority: P2.
- [ ] **v0.42+: `--explain` human header for adaptive_return.** The decision is in `HybridSearchMeta.adaptive_return` and surfaces in `--json` today. The per-result `explain-formatter.ts` is result-scoped and can't render a per-query meta line; the human `gbrain search --explain` header needs the meta threaded through `cli.ts:formatResult` (it currently only receives `results`). Add a one-line gate-decision header (intent / cap / kept of total). Priority: P3.
- [ ] **v0.42+: structured-alias / facts-mode fidelity for the PrecisionMemBench eval.** The gbrain-evals benchmark seeds beliefs as pages with aliases in the body (real FTS). A second fidelity that exercises gbrain's structured alias/entity-resolution layer (facts with `valid_until` + entity resolution) would measure gbrain's structured-belief path on the 23 alias cases. Lives in gbrain-evals (`eval/precisionmembench/seed.ts` throws on `fidelity:'structured'` today). Priority: P3.
## v0.41.32.0 content-relative staleness follow-ups (v0.42+)
Filed from the v0.41.32.0 wave (supersedes #1623 — commit-relative sync
+1 -1
View File
@@ -1 +1 @@
0.41.32.0
0.41.33.0
+1
View File
@@ -265,6 +265,7 @@ strict behavior when unset.
- `src/core/rerank-audit.ts` (v0.35.0.0) — failure-only JSONL audit at `~/.gbrain/audit/rerank-failures-YYYY-Www.jsonl` (ISO-week rotation, mirrors `src/core/audit-slug-fallback.ts`). Exports `logRerankFailure({reason, model, query_hash, doc_count, error_summary})` + `readRecentRerankFailures(days)`. **Deliberately no `logRerankSuccess`** (CDX2-F22 in plan review): writing once per tokenmax search is hot-path I/O churn AND success events leak query volume + timing into a local audit file. `gbrain doctor`'s `reranker_health` check reads `search.reranker.enabled` first so "no events in window" is interpreted correctly (disabled → ok; enabled → ok). Query text is SHA-256-prefix-hashed (8 hex chars) for privacy. `GBRAIN_AUDIT_DIR` env override honored via the shared `resolveAuditDir()`.
- `src/core/search/embedding-column.ts` (v0.36.3.0) — single source of truth for "which `content_chunks.*` column does this query rank against?" Pure functions, no engine I/O: `loadRegistry(cfg)` walks the `embedding_columns` config (DB plane, JSON map keyed by column name with `{provider, dimensions, type}` entries), seeds the OpenAI `embedding` builtin when unset, validates everything before it lands (column-name regex, type ∈ `vector | halfvec`, dims in [1, 8192], provider format) using `Object.create(null)` + `Object.hasOwn` so a key like `constructor` rejects instead of resolving to `Object.prototype.constructor`. `resolveColumn(registry, override?, cfg)` is the boundary call: returns a `ResolvedColumn` descriptor (frozen `{name, provider, dimensions, type}`) honoring per-call override → `search_embedding_column` config → `'embedding'` default. Throws `UnknownEmbeddingColumnError` with the list of registered names on miss. `isCacheSafe(resolved, cfg)` compares the full embedding SPACE (provider + dimensions + name) against cfg's default — a user who repointed the `embedding` builtin at Voyage doesn't accidentally serve OpenAI-shaped cache rows. `validateResolvedColumn(descriptor)` re-validates hand-rolled descriptors that bypass the registry (internal-SDK passthrough path) so the SQL-injection escape hatch through the descriptor field is closed. Consumed by `hybridSearch` (resolves once at the boundary, threads a `ResolvedColumn` into per-engine `searchVector` instead of a raw string), `gateway.embedQuery(text, {embeddingModel, dimensions})` (resolved column's provider drives the query-time embed call), `cosineReScore` (engine pulls vectors from the active column, not the hardcoded `embedding`), and the `query` MCP op (per-call `embedding_column` param). 538 lines of pure resolver, 511 unit cases in `test/search/embedding-column.test.ts` covering the 16 codex-flagged corners (prototype-pollution, descriptor passthrough, env-only Postgres install, empty-brain coverage gate, cache-space comparison).
- `src/core/search/rerank.ts` (v0.35.0.0) — the call-site abstraction. `applyReranker(query, results, opts)` slots between `dedupResults()` and `enforceTokenBudget()` in `src/core/search/hybrid.ts`. Slices `opts.topNIn` (default 30) by current RRF order, sends to `gateway.rerank()`, reorders by `relevanceScore` desc, and appends the un-reranked tail unchanged (recall protection). **Fail-open on every `RerankError.reason`**: any error logs via `logRerankFailure` and returns the input array unchanged. Stamps `rerank_score` onto reordered items so downstream telemetry sees the new ordering signal. `topNOut: null` is the explicit "don't truncate" signal — semantically distinct from `undefined` which means "fall through to mode bundle" (CDX2-F16). Test seam: `opts.rerankerFn` lets tests stub `gateway.rerank` without touching the network.
- `src/core/search/return-policy.ts` (v0.41.33.0, NEW — default OFF) — intent-aware adaptive return-sizing. Pure, dependency-light module that trims the final ranked candidate set to an intent-driven cap instead of always returning the full top-K (noisy) list. `entity` intent (single-answer-ish) gets a tight cap; `temporal`/`event`/`general` (enumeration-ish) get a recall-preserving cap. A `minKeep` failsafe (≥1) guarantees a human never gets a silent blank when candidates exist. WHY a cap, not a score-cliff detector: PrecisionMemBench Phase-1 instrumentation (gbrain-evals) measured that the rank1→rank2 RRF score gap is ~identical whether rank-1 is correct (0.602) or wrong (0.569) — mechanical decay, not a trustworthy separatrix; rank-1 is right in 94% of single-answer cases, so "return a tight set" is the whole win and cliff-cutting just adds noise. Exports `AdaptiveReturnConfig`, `DEFAULT_ADAPTIVE_RETURN` (frozen: `enabled=false`, `entityMax=2`, `otherMax=6`, `minKeep=1`), `AdaptiveReturnDecision` (`{applied, intent, cap, kept, total}`), `AdaptiveReturnInput` (`boolean | Partial<AdaptiveReturnConfig> | undefined`), `adaptiveReturnFromConfig(cfg)` (reads the config plane), `resolveAdaptiveReturn(perCall, fromConfig)` (defaults → config → per-call merge), `adaptiveReturnEnabled(...)` (gate check for the cache skip), and `applyAdaptiveReturn(results, intent, cfg)` (the trim). Config knobs (DB or file plane): `search.adaptive_return` (master switch, boolean), `search.adaptive_return_entity_max`, `search.adaptive_return_other_max`, `search.adaptive_return_min_keep` (each clamped to ≥1). Precision-sensitive agents tune entity=1, other=1 ≈ "return only the top result." Wired into `hybridSearch` (`src/core/search/hybrid.ts`): applied AFTER `applyReranker`, BEFORE the `limit` slice, and ONLY on the first page (`offset===0`) — paginating a confidence-gated set is incoherent, so paginated calls fall through to the fixed limit. Stamps the decision onto `HybridSearchMeta.adaptive_return` for `gbrain search --explain`. `hybridSearchCached` SKIPS the cache when the gate is on (a trimmed set must not be served to a gate-off lookup and vice versa); folding the gate params into `KNOBS_HASH` so adaptive-on calls cache safely is a v0.42+ follow-up before any mode-default flip. `SearchOpts.adaptiveReturn` + `HybridSearchMeta.adaptive_return` declared in `src/core/types.ts`. **Agent-facing surface (v0.41.33.0):** the `query` op (`src/core/operations.ts`) exposes an `adaptive_return` boolean param whose description instructs the agent WHEN to set it (single-answer questions → on; breadth/exploration → off; pass `limit:1` for a hard single-answer cap), threaded into `hybridSearchCached`. This is the path that matters — end users never touch the config knob; their agent decides per query (same pattern as `salience`/`recency`). Pinned by `test/search/return-policy.test.ts` (mechanism) + `test/search/query-op-adaptive-return.test.ts` (agent surface: param exists + description teaches both directions + the never-empty contract).
- `src/core/ai/recipes/voyage.ts` — Voyage AI openai-compatible recipe. **v0.28.7 (#680):** declares `chars_per_token=1` + `safety_factor=0.5` so the gateway pre-splits Voyage batches at a 60K-character budget (50% of 120K-token cap with the dense-tokenizer ratio). Closes the v0.27 backfill loop where ~26% of the corpus stayed un-embedded because tiktoken-grounded budgeting silently undercounted Voyage's actual token usage. **v0.28.11 (#719):** declares `multimodal_models: ['voyage-multimodal-3']` so the gateway rejects text-only Voyage models pointed at the multimodal endpoint with a clear `AIConfigError` instead of waiting for Voyage's HTTP 400. **v0.33.1.1 (#962, fixup):** recipe docstring at `:7-16` tightened to name the seven hosted flexible-dim models that accept `output_dimension` explicitly (`voyage-4-large`, `voyage-4`, `voyage-4-lite`, `voyage-3-large`, `voyage-3.5`, `voyage-3.5-lite`, `voyage-code-3`) and call out that `voyage-4-nano` is the open-weight variant listed separately by Voyage as fixed 1024-dim — does NOT accept the parameter. The "all v4 variants are flexible" misread is what caused the original PR to include nano in `VOYAGE_OUTPUT_DIMENSION_MODELS`; the negative regression assertion in `test/ai/gateway.test.ts` (`dimsProviderOptions` returns `undefined` for `voyage-4-nano`) pins the contract. **v0.37.3.0:** `voyage-code-3` is the recommended embedding model for gstack per-worktree code brains (Topology 3 in `docs/architecture/topologies.md`). Registration was already in the `models` list since pre-v0.33; the v0.37.3.0 wave adds discoverability surfaces — decision-tree branch in `docs/integrations/embedding-providers.md`, Topology 3 "Recommended embedding model" subsection, runtime nudge from `gbrain reindex --code` against non-code-tuned models. Recipe-shape regression pinned by `test/ai/voyage-code-3-recipe.test.ts`.
- `src/core/ai/recipes/anthropic.ts` — Anthropic recipe (chat + expansion touchpoints). **v0.31.12:** chat and expansion `models:` lists drop the v0.31.6 phantom `claude-sonnet-4-6-20250929` date suffix — canonical id is `claude-sonnet-4-6`. The wrong-direction alias `claude-sonnet-4-6 → claude-sonnet-4-6-20250929` is removed; a reverse alias `claude-sonnet-4-6-20250929 → claude-sonnet-4-6` keeps stale user configs working (rescues `facts.extraction_model` and `models.dream.synthesize` set by v0.31.6 installs). Recipe-shape regression pinned by `test/anthropic-model-ids.test.ts` (6 cases, verbatim cherry-pick of PR #830 plus the reverse-alias rescue case).
- `src/core/anthropic-pricing.ts` — Single source of truth for Anthropic model pricing (per-MTok input/output). **v0.31.12:** Opus 4.7 corrected from `$15/$75` to `$5/$25` (the old number was from Opus 4 generation, never refreshed when 4.7 shipped); Opus 4.6 also corrected. Consumed by `src/core/budget-meter.ts` and `src/core/cross-modal-eval/runner.ts` — the cross-modal estimator now reads `ANTHROPIC_PRICING` for Anthropic models instead of duplicating the table, killing the v0.31.6 drift bug class.
+1 -1
View File
@@ -141,5 +141,5 @@
"bun": ">=1.3.10"
},
"license": "MIT",
"version": "0.41.32.0"
"version": "0.41.33.0"
}
+3
View File
@@ -444,6 +444,9 @@ export interface StripFactsFenceOpts {
* Returns the body unchanged when no fence is present.
*/
export function stripFactsFence(body: string, opts: StripFactsFenceOpts = {}): string {
// Pages without a compiled body have nothing to strip. Guard so the privacy
// strip is a safe no-op rather than crashing on `undefined.indexOf`.
if (typeof body !== 'string') return body;
const beginIdx = body.indexOf(FACTS_FENCE_BEGIN);
if (beginIdx === -1) return body;
const endIdx = body.indexOf(FACTS_FENCE_END, beginIdx + FACTS_FENCE_BEGIN.length);
+11
View File
@@ -1318,6 +1318,14 @@ const query: Operation = {
description:
"v0.36: route vector search through a non-default embedding column. Defaults to 'embedding' (OpenAI 1536d) unless `search_embedding_column` config sets a different default. Per-call override for A/B benchmarking across providers (e.g. 'embedding_voyage', 'embedding_zeroentropy'). Column MUST be declared in the `embedding_columns` config registry — unknown names throw with a paste-ready hint listing valid columns.",
},
adaptive_return: {
type: 'boolean',
description:
"v0.41.33 — return a TIGHT, intent-sized result set instead of the full top-K. YOU (the agent) set this per query to serve the user well:\n" +
" TRUE when the user's question has a small, specific answer — a lookup ('what is X', 'who is Y', 'what's my <thing>', 'what did Z decide'), a single-fact recall, or when you'll route the result into a precise downstream step (a classifier, a decision, an exact citation). The user gets the answer, not a wall of loosely-related pages, and you spend fewer tokens reading noise.\n" +
" Omit / FALSE for breadth — 'everything about X', 'list all', 'what do I know about Y', exploration, brainstorming, or any time you'd rather see more candidates and judge for yourself. Recall matters more there, so take the full top-K.\n" +
"Safe by construction: it NEVER returns empty when there are matches (you always get at least the top hit), and it only applies to the first page (omit when paginating). Caps come from config (search.adaptive_return_entity_max / _other_max; default 2 / 6) — pass `limit` 1 alongside this for a hard single-answer cap.",
},
},
handler: async (ctx, p) => {
const startedAt = Date.now();
@@ -1407,6 +1415,9 @@ const query: Operation = {
// Source scope is already threaded via ...querySourceScope above
// (master's #1182 cleanup of the duplicate sourceScopeOpts spread).
embeddingColumn: embeddingColumnParam,
// v0.41.33 — agent-explicit adaptive return-sizing. Omitted = off
// (config default applies). hybridSearchCached skips the cache when on.
adaptiveReturn: typeof p.adaptive_return === 'boolean' ? (p.adaptive_return as boolean) : undefined,
});
const latency_ms = Date.now() - startedAt;
+35 -2
View File
@@ -14,6 +14,13 @@ import { MAX_SEARCH_LIMIT, clampSearchLimit } from '../engine.ts';
import type { SearchResult, SearchOpts, HybridSearchMeta } from '../types.ts';
import { embed, embedQuery } from '../embedding.ts';
import { resolveEmbeddingColumn, isCacheSafe } from './embedding-column.ts';
import {
resolveAdaptiveReturn,
applyAdaptiveReturn,
adaptiveReturnFromConfig,
adaptiveReturnEnabled,
type AdaptiveReturnDecision,
} from './return-policy.ts';
import { loadConfigWithEngine } from '../config.ts';
import { dedupResults } from './dedup.ts';
import { applyReranker } from './rerank.ts';
@@ -1028,7 +1035,23 @@ export async function hybridSearch(
? await applyReranker(query, deduped, rerankerOpts as any)
: deduped;
const sliced = reranked.slice(offset, offset + limit);
// v0.42 — intent-aware adaptive return-sizing (opt-in, default off). Trim
// the ranked candidate set to an intent-driven cap BEFORE the limit slice,
// and only on the first page (offset===0) — paginating a confidence-gated
// set is incoherent, so paginated calls fall through to the fixed limit.
const adaptiveCfg = resolveAdaptiveReturn(
opts?.adaptiveReturn,
adaptiveReturnFromConfig(cfgForColumn as Record<string, unknown> | null),
);
let returnPool = reranked;
let adaptiveDecision: AdaptiveReturnDecision | undefined;
if (adaptiveCfg.enabled && offset === 0) {
const r = applyAdaptiveReturn(reranked, suggestions.intent, adaptiveCfg);
returnPool = r.kept;
adaptiveDecision = r.decision;
}
const sliced = returnPool.slice(offset, offset + limit);
// v0.32.3 search-lite: budget enforcement at the main return path.
// hybridSearchCached used to be the only place this fired; now bare
// hybridSearch enforces it too so eval-replay + eval-longmemeval see
@@ -1045,6 +1068,7 @@ export async function hybridSearch(
...(resolvedMode.tokenBudget && resolvedMode.tokenBudget > 0
? { token_budget: budgetMeta }
: {}),
...(adaptiveDecision ? { adaptive_return: adaptiveDecision } : {}),
});
return budgeted;
}
@@ -1140,11 +1164,20 @@ export async function hybridSearchCached(
// a non-default embedding column (per-call or via config default —
// D8 closes the silent-corruption bug class), or near-symbol mode
// (structural state that the cache can't safely express).
// v0.42 — when adaptive return-sizing is on, skip the cache: a gated
// (trimmed) result set must not be served to a gate-off lookup, and vice
// versa. Folding the gate params into knobsHash is the v0.42+ follow-up
// (TODO) that lets adaptive-on calls cache safely; until then, skip.
const adaptiveReturnOn = adaptiveReturnEnabled(
opts?.adaptiveReturn,
cfgCached as unknown as Record<string, unknown> | null,
);
const skipCache =
!cache.isEnabled() ||
(opts?.walkDepth ?? 0) > 0 ||
Boolean(opts?.nearSymbol) ||
isNonDefaultColumn;
isNonDefaultColumn ||
adaptiveReturnOn;
let cacheStatus: 'hit' | 'miss' | 'disabled' = skipCache ? 'disabled' : 'miss';
let cacheSimilarity: number | undefined;
+6 -1
View File
@@ -918,7 +918,12 @@ export async function loadSearchModeConfig(
const safeGet = async (k: string): Promise<string | undefined> => {
try {
const v = await engine.getConfig(k);
return v == null ? undefined : v;
// getConfig's contract is string | null, but guard against engines that
// return non-string junk (e.g. arrays/booleans). A non-string value is
// treated as "not set" so it falls through to the mode-bundle default,
// matching the behavior of a missing key. Without this, downstream
// parsing (e.g. ce.toLowerCase()) crashes on a non-string.
return typeof v === 'string' ? v : undefined;
} catch {
return undefined;
}
+131
View File
@@ -0,0 +1,131 @@
/**
* return-policy.ts — intent-aware adaptive return-sizing (v0.42).
*
* Opt-in retrieval feature: instead of returning the full top-K candidate
* list (noisy), return a tight set sized by query intent. Single-answer-ish
* queries (`entity`) get a small cap; enumeration-ish queries
* (`temporal`/`event`/`general`) get a larger cap. An at-least-`minKeep`
* failsafe guarantees a human never gets a silent blank when candidates exist.
*
* WHY intent-driven caps and NOT a score-cliff detector: the PrecisionMemBench
* Phase-1 instrumentation (gbrain-evals) measured that the rank1→rank2 score
* gap is ~identical whether rank-1 is correct (0.602) or wrong (0.569) — RRF's
* mechanical decay, not a trustworthy separatrix. The right belief is rank-1 in
* 94% of single-answer cases, so "return a tight set" is the whole win;
* cliff-cutting just adds noise. So the mechanism is a cap, with intent as the
* (admittedly coarse) prior on how many answers the query wants.
*
* Default OFF. Cache-safe via a skip in hybridSearchCached when enabled (the
* trimmed set must not be served to a gate-off lookup); folding the params into
* KNOBS_HASH is a v0.42+ follow-up before any mode-default flip.
*
* Pure + dependency-light so it unit-tests in isolation.
*/
export type AdaptiveQueryIntent = 'entity' | 'temporal' | 'event' | 'general';
export interface AdaptiveReturnConfig {
/** Master switch. Default false — no behavior change for existing callers. */
enabled: boolean;
/** Cap for single-answer-ish (`entity`) intent. */
entityMax: number;
/** Cap for `temporal` / `event` / `general` intent (recall-preserving). */
otherMax: number;
/** Failsafe: never return fewer than this when candidates exist (≥1). */
minKeep: number;
}
/**
* Recall-preserving defaults (the product posture, not benchmark-maxing).
* entity=2 keeps the dominant answer + one hedge; other=6 trims the long noisy
* tail while keeping enumeration recall high. Precision-sensitive agents tune
* these down (entity=1, other=1 ≈ "return only the top result").
*/
export const DEFAULT_ADAPTIVE_RETURN: AdaptiveReturnConfig = Object.freeze({
enabled: false,
entityMax: 2,
otherMax: 6,
minKeep: 1,
});
export interface AdaptiveReturnDecision {
applied: boolean;
intent: AdaptiveQueryIntent;
cap: number;
kept: number;
total: number;
}
/** Per-call SearchOpts shape: `true`/`false` toggles, or a partial override. */
export type AdaptiveReturnInput = boolean | Partial<AdaptiveReturnConfig> | undefined;
function clampInt(v: unknown, fallback: number, min: number): number {
const n = typeof v === 'number' ? Math.floor(v) : Number.NaN;
return Number.isFinite(n) && n >= min ? n : fallback;
}
/** Read adaptive-return defaults from a loaded config object (DB or file plane). */
export function adaptiveReturnFromConfig(
cfg: Record<string, unknown> | null | undefined,
): Partial<AdaptiveReturnConfig> {
const search = (cfg?.search ?? {}) as Record<string, unknown>;
const out: Partial<AdaptiveReturnConfig> = {};
if (typeof search.adaptive_return === 'boolean') out.enabled = search.adaptive_return;
if (search.adaptive_return_entity_max !== undefined)
out.entityMax = clampInt(search.adaptive_return_entity_max, DEFAULT_ADAPTIVE_RETURN.entityMax, 1);
if (search.adaptive_return_other_max !== undefined)
out.otherMax = clampInt(search.adaptive_return_other_max, DEFAULT_ADAPTIVE_RETURN.otherMax, 1);
if (search.adaptive_return_min_keep !== undefined)
out.minKeep = clampInt(search.adaptive_return_min_keep, DEFAULT_ADAPTIVE_RETURN.minKeep, 1);
return out;
}
/** Merge defaults → config-plane → per-call into a concrete config. */
export function resolveAdaptiveReturn(
perCall: AdaptiveReturnInput,
fromConfig?: Partial<AdaptiveReturnConfig>,
): AdaptiveReturnConfig {
const base: AdaptiveReturnConfig = { ...DEFAULT_ADAPTIVE_RETURN, ...(fromConfig ?? {}) };
if (perCall === undefined) return base;
if (perCall === true) return { ...base, enabled: true };
if (perCall === false) return { ...base, enabled: false };
return {
...base,
...perCall,
enabled: perCall.enabled ?? base.enabled,
};
}
/** True iff the gate is on (per-call or config). Used for the cache skip. */
export function adaptiveReturnEnabled(
perCall: AdaptiveReturnInput,
cfg: Record<string, unknown> | null | undefined,
): boolean {
return resolveAdaptiveReturn(perCall, adaptiveReturnFromConfig(cfg)).enabled;
}
/**
* Trim a ranked result list to the intent-driven cap. Input MUST already be in
* final ranked order. Returns the kept prefix + a decision record. Never
* returns empty when `results` is non-empty (at-least-minKeep failsafe).
*/
export function applyAdaptiveReturn<T>(
results: T[],
intent: AdaptiveQueryIntent,
cfg: AdaptiveReturnConfig,
): { kept: T[]; decision: AdaptiveReturnDecision } {
if (!cfg.enabled || results.length === 0) {
return {
kept: results,
decision: { applied: false, intent, cap: results.length, kept: results.length, total: results.length },
};
}
const cap = intent === 'entity' ? cfg.entityMax : cfg.otherMax;
const minKeep = Math.max(1, cfg.minKeep);
const keep = Math.max(minKeep, Math.min(cap, results.length));
const kept = results.slice(0, keep);
return {
kept,
decision: { applied: true, intent, cap, kept: kept.length, total: results.length },
};
}
+4
View File
@@ -547,6 +547,10 @@ export function supersedeRow(
* unchanged.
*/
export function stripTakesFence(body: string): string {
// Pages without a compiled body (e.g. metadata-only rows from a read op)
// have nothing to strip. Guard so the privacy strip is a safe no-op rather
// than crashing on `undefined.indexOf`.
if (typeof body !== 'string') return body;
const beginIdx = body.indexOf(TAKES_FENCE_BEGIN);
if (beginIdx === -1) return body;
const endIdx = body.indexOf(TAKES_FENCE_END, beginIdx + TAKES_FENCE_BEGIN.length);
+13
View File
@@ -723,6 +723,14 @@ export interface ResolvedColumn {
export interface SearchOpts {
limit?: number;
offset?: number;
/**
* v0.42 — intent-aware adaptive return-sizing. `true` enables with config/
* default caps; an object overrides caps per-call; omitted/`false` = off
* (default, no behavior change). Trims the ranked set to an intent-driven
* cap (entity → tight, else → recall-preserving). Only fires when offset===0.
* See src/core/search/return-policy.ts.
*/
adaptiveReturn?: import('./search/return-policy.ts').AdaptiveReturnInput;
type?: PageType;
/**
* v0.33: multi-type filter. When set, search results are filtered to
@@ -1253,6 +1261,11 @@ export interface HybridSearchMeta {
* weighting decision auditable.
*/
intent?: 'entity' | 'temporal' | 'event' | 'general';
/**
* v0.42 — adaptive return-sizing decision (intent, cap, kept, total).
* Omitted when the gate is off. Surfaced for `gbrain search --explain`.
*/
adaptive_return?: import('./search/return-policy.ts').AdaptiveReturnDecision;
/**
* v0.32.x (search-lite): token budget enforcement metadata. Omitted when
* no budget was applied (backward-compatible with pre-search-lite
@@ -0,0 +1,33 @@
/**
* Pins the agent-facing surface for adaptive return-sizing on the `query` op.
*
* The feature is useless to an agent unless the MCP/op tool schema exposes the
* param AND its description teaches WHEN to reach for it. This test guards both
* so a future refactor can't silently drop the agent instruction.
*/
import { describe, expect, test } from 'bun:test';
import { operationsByName } from '../../src/core/operations.ts';
describe('query op — adaptive_return agent surface', () => {
const query = operationsByName['query'];
test('query op exists', () => {
expect(query).toBeDefined();
});
test('adaptive_return is a boolean param on query', () => {
const param = query.params?.adaptive_return as { type?: string; description?: string } | undefined;
expect(param).toBeDefined();
expect(param?.type).toBe('boolean');
});
test('description teaches the agent WHEN to use it (single-answer vs breadth)', () => {
const desc = (query.params?.adaptive_return as { description?: string })?.description ?? '';
// Must instruct the agent on both directions of the decision.
expect(desc.toLowerCase()).toContain('true when');
expect(desc.toLowerCase()).toContain('breadth');
// Must reassure the safety contract so the agent isn't afraid to use it.
expect(desc.toLowerCase()).toContain('never returns empty');
});
});
+129
View File
@@ -0,0 +1,129 @@
/**
* Unit tests for intent-aware adaptive return-sizing (v0.42).
* Pure logic — no engine, no LLM. Pins the failsafe (never-empty),
* intent→cap mapping, config resolution precedence, and the off-by-default
* contract.
*/
import { describe, expect, test } from 'bun:test';
import {
DEFAULT_ADAPTIVE_RETURN,
adaptiveReturnFromConfig,
resolveAdaptiveReturn,
adaptiveReturnEnabled,
applyAdaptiveReturn,
type AdaptiveReturnConfig,
} from '../../src/core/search/return-policy.ts';
const items = (n: number): string[] => Array.from({ length: n }, (_, i) => `r${i}`);
describe('resolveAdaptiveReturn precedence', () => {
test('default is OFF (no behavior change)', () => {
expect(resolveAdaptiveReturn(undefined).enabled).toBe(false);
expect(DEFAULT_ADAPTIVE_RETURN.enabled).toBe(false);
});
test('per-call true enables with default caps', () => {
const c = resolveAdaptiveReturn(true);
expect(c.enabled).toBe(true);
expect(c.entityMax).toBe(DEFAULT_ADAPTIVE_RETURN.entityMax);
});
test('per-call false wins over config-enabled', () => {
const c = resolveAdaptiveReturn(false, { enabled: true });
expect(c.enabled).toBe(false);
});
test('per-call object overrides caps but inherits enabled from config when omitted', () => {
const c = resolveAdaptiveReturn({ entityMax: 1 }, { enabled: true, otherMax: 9 });
expect(c.enabled).toBe(true); // from config
expect(c.entityMax).toBe(1); // per-call override
expect(c.otherMax).toBe(9); // from config
});
test('config plane enables', () => {
const c = resolveAdaptiveReturn(undefined, { enabled: true });
expect(c.enabled).toBe(true);
});
});
describe('adaptiveReturnFromConfig', () => {
test('reads nested search.* keys with clamping', () => {
const c = adaptiveReturnFromConfig({
search: {
adaptive_return: true,
adaptive_return_entity_max: 3,
adaptive_return_other_max: 8,
adaptive_return_min_keep: 2,
},
});
expect(c).toEqual({ enabled: true, entityMax: 3, otherMax: 8, minKeep: 2 });
});
test('rejects sub-1 / non-numeric caps (falls back to defaults)', () => {
const c = adaptiveReturnFromConfig({
search: { adaptive_return_entity_max: 0, adaptive_return_other_max: 'x' },
});
expect(c.entityMax).toBe(DEFAULT_ADAPTIVE_RETURN.entityMax);
expect(c.otherMax).toBe(DEFAULT_ADAPTIVE_RETURN.otherMax);
});
test('empty / null config → empty partial', () => {
expect(adaptiveReturnFromConfig(null)).toEqual({});
expect(adaptiveReturnFromConfig({})).toEqual({});
});
});
describe('adaptiveReturnEnabled', () => {
test('true when per-call true', () => {
expect(adaptiveReturnEnabled(true, null)).toBe(true);
});
test('true when config enables', () => {
expect(adaptiveReturnEnabled(undefined, { search: { adaptive_return: true } })).toBe(true);
});
test('false by default', () => {
expect(adaptiveReturnEnabled(undefined, null)).toBe(false);
});
});
const ON = (over: Partial<AdaptiveReturnConfig> = {}): AdaptiveReturnConfig => ({
...DEFAULT_ADAPTIVE_RETURN,
enabled: true,
...over,
});
describe('applyAdaptiveReturn', () => {
test('disabled → passthrough, decision.applied=false', () => {
const { kept, decision } = applyAdaptiveReturn(items(20), 'entity', DEFAULT_ADAPTIVE_RETURN);
expect(kept.length).toBe(20);
expect(decision.applied).toBe(false);
});
test('entity intent uses entityMax', () => {
const { kept, decision } = applyAdaptiveReturn(items(20), 'entity', ON({ entityMax: 2 }));
expect(kept.length).toBe(2);
expect(decision.cap).toBe(2);
expect(decision.applied).toBe(true);
});
test('non-entity intent uses otherMax', () => {
for (const intent of ['temporal', 'event', 'general'] as const) {
const { kept } = applyAdaptiveReturn(items(20), intent, ON({ otherMax: 5 }));
expect(kept.length).toBe(5);
}
});
test('FAILSAFE: never empty when candidates exist (cap floored to minKeep)', () => {
// minKeep defaults to 1; even a degenerate cap can't zero the result.
const { kept } = applyAdaptiveReturn(items(10), 'entity', ON({ entityMax: 1, minKeep: 1 }));
expect(kept.length).toBe(1);
});
test('empty input stays empty (no fabrication)', () => {
const { kept, decision } = applyAdaptiveReturn([], 'entity', ON());
expect(kept.length).toBe(0);
expect(decision.applied).toBe(false);
});
test('cap larger than result set keeps all', () => {
const { kept } = applyAdaptiveReturn(items(2), 'general', ON({ otherMax: 6 }));
expect(kept.length).toBe(2);
});
test('preserves order (returns the ranked prefix)', () => {
const { kept } = applyAdaptiveReturn(items(5), 'entity', ON({ entityMax: 3 }));
expect(kept).toEqual(['r0', 'r1', 'r2']);
});
test('minKeep > cap still respected (recall floor wins)', () => {
const { kept } = applyAdaptiveReturn(items(10), 'entity', ON({ entityMax: 1, minKeep: 3 }));
expect(kept.length).toBe(3);
});
});