Files
gbrain/src/commands/search.ts
T
d6db3f0ce3 v0.41.34.0 feat(search): retrieval cathedral — max-pool + title + alias + evidence (#1657)
* 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>
2026-05-30 12:07:38 -07:00

577 lines
23 KiB
TypeScript

/**
* v0.32.3 — `gbrain search` CLI surface.
*
* Three sub-subcommands, mirroring `gbrain models` (v0.31.12) for shape
* consistency:
*
* gbrain search modes [--json]
* Read-only routing dashboard. Prints the three mode bundles, the
* active mode, the source of every resolved knob (mode default vs
* config override vs per-call), and a one-liner per knob.
*
* gbrain search modes --reset [--source <mode>]
* Clears every search.* override key (per CDX-8). --source acts as
* a dry-run that lists what would change without writing.
*
* gbrain search stats [--days N] [--json]
* Observability. Reads search_telemetry rollup over the window.
* Shows hit rate %, intent mix, mode mix, budget pressure, avg
* results, avg tokens delivered.
*
* gbrain search tune [--apply] [--json]
* Recommendation engine. Reads stats + brain size + model tier and
* prints structured recommendations. --apply mutates config (each
* change logged loud + paste-ready revert command at the end).
*/
import type { BrainEngine } from '../core/engine.ts';
import {
MODE_BUNDLES,
SEARCH_MODES,
SEARCH_MODE_KEY,
SEARCH_MODE_CONFIG_KEYS,
DEFAULT_SEARCH_MODE,
isSearchMode,
loadSearchModeConfig,
resolveSearchMode,
attributeKnob,
type SearchMode,
type ModeBundle,
} from '../core/search/mode.ts';
import { readSearchStats } from '../core/search/telemetry.ts';
const KNOB_DESCRIPTIONS: Record<keyof ModeBundle, string> = {
cache_enabled: 'Semantic query cache on/off',
cache_similarity_threshold: 'Cosine-similarity floor for cache hits (0..1)',
cache_ttl_seconds: 'Per-row cache TTL',
intentWeighting: 'Zero-LLM intent classifier weight adjustments',
tokenBudget: 'Per-call token-budget cap (undefined = no cap)',
expansion: 'LLM multi-query expansion (Haiku call per search)',
searchLimit: 'Default `limit` for the operation layer',
reranker_enabled: 'Cross-encoder reranker (ZE zerank-2) on/off',
reranker_model: 'Provider:model for the reranker',
reranker_top_n_in: 'Candidates sent to reranker per call',
reranker_top_n_out: 'Cap on reranked output (null = no truncate)',
reranker_timeout_ms: 'HTTP timeout for the reranker call',
floor_ratio: 'Floor-ratio gate for metadata boosts (0..1, undefined = off)',
title_boost: 'Title-phrase boost multiplier (query is a title token-run; 1.0 = off)',
// v0.36 cross-modal knobs (D3 registry)
cross_modal_both_text_weight: "D6 'both'-mode RRF weight for text branch (0.6 default)",
cross_modal_both_image_weight: "D6 'both'-mode RRF weight for image branch (0.4 default)",
image_query_text_refinement_weight: 'D13 searchByImage text-refinement RRF weight (0.4 default)',
image_query_image_refinement_weight: 'D13 searchByImage image branch RRF weight (0.6 default)',
unified_multimodal: 'Phase 3 — route all queries through embedding_multimodal column',
unified_multimodal_only: 'Phase 3 strict — bypass dual-column fallback when unified is on',
cross_modal_llm_intent: 'Commit 4 — Haiku tie-break for ambiguous modality classification',
// v0.40.4 graph signals
graph_signals: 'Selective graph signals: adjacency hub + cross-source hub + session diversification',
// v0.40.3.0 contextual retrieval
contextual_retrieval: 'CR tier (none|title|per_chunk_synopsis) — wraps chunks at embed time',
contextual_retrieval_disabled: 'Soft kill switch — neutralizes CR wrapping for queries + new embeds',
};
interface SearchModesReport {
schema_version: 2;
active_mode: SearchMode;
active_mode_valid: boolean;
resolved: Record<keyof ModeBundle, { value: unknown; source: string; source_detail: string; description: string }>;
bundles: Record<SearchMode, ModeBundle>;
config_keys: ReadonlyArray<string>;
_meta?: {
metric_glossary?: Record<string, string>;
};
}
async function buildModesReport(engine: BrainEngine): Promise<SearchModesReport> {
const input = await loadSearchModeConfig(engine);
const resolved = resolveSearchMode(input);
const knobs: Array<keyof ModeBundle> = [
'cache_enabled',
'cache_similarity_threshold',
'cache_ttl_seconds',
'intentWeighting',
'tokenBudget',
'expansion',
'searchLimit',
// v0.35.6.0 — floor-ratio surfaced in `gbrain search modes` dashboard
// so config drift is legible. Default undefined renders as 'undefined'
// in the bundle column, 'mode' source when unset by config/per-call.
'floor_ratio',
];
const attributions = {} as SearchModesReport['resolved'];
for (const k of knobs) {
const a = attributeKnob(k, input, resolved);
attributions[k] = {
value: a.value,
source: a.source,
source_detail: a.source_detail,
description: KNOB_DESCRIPTIONS[k],
};
}
return {
schema_version: 2,
active_mode: resolved.resolved_mode,
active_mode_valid: resolved.mode_valid,
resolved: attributions,
bundles: {
conservative: { ...MODE_BUNDLES.conservative },
balanced: { ...MODE_BUNDLES.balanced },
tokenmax: { ...MODE_BUNDLES.tokenmax },
},
config_keys: SEARCH_MODE_CONFIG_KEYS,
};
}
function formatModesText(report: SearchModesReport): string {
const lines: string[] = [];
lines.push('Search mode (active): ' + report.active_mode + (report.active_mode_valid ? '' : ' (unset — using balanced fallback)'));
lines.push('');
lines.push('Resolved knobs:');
for (const [knob, attr] of Object.entries(report.resolved)) {
const value = String(attr.value ?? '(undefined)');
lines.push(` ${knob.padEnd(28)} = ${value.padEnd(12)} [${attr.source_detail}]`);
}
lines.push('');
lines.push('Mode bundles (frozen — set via `gbrain config set search.mode <mode>`):');
for (const mode of SEARCH_MODES) {
const b = report.bundles[mode];
const active = mode === report.active_mode ? ' ← active' : '';
lines.push(` ${mode.padEnd(13)}${active}`);
lines.push(` cache=${b.cache_enabled} intentWeighting=${b.intentWeighting}`);
lines.push(` tokenBudget=${b.tokenBudget ?? 'none'} searchLimit=${b.searchLimit} expansion=${b.expansion}`);
}
lines.push('');
lines.push('Knob descriptions:');
for (const [k, desc] of Object.entries(KNOB_DESCRIPTIONS)) {
lines.push(` ${k.padEnd(28)} ${desc}`);
}
return lines.join('\n');
}
async function runModesSubcommand(engine: BrainEngine, args: string[]): Promise<void> {
const json = args.includes('--json');
const reset = args.includes('--reset');
const sourceIdx = args.indexOf('--source');
const dryRunSource = sourceIdx !== -1 ? args[sourceIdx + 1] : null;
// --reset path: clear every search.* OVERRIDE key (not search.mode itself).
// --source <mode> is a dry-run that prints what would change.
if (reset || dryRunSource) {
const dryRun = Boolean(dryRunSource);
if (dryRunSource && !isSearchMode(dryRunSource)) {
console.error(`Invalid --source value: ${dryRunSource}. Expected one of: ${SEARCH_MODES.join(', ')}`);
process.exit(1);
}
const overrides = await engine.listConfigKeys('search.');
const toRemove = overrides.filter((k) => k !== SEARCH_MODE_KEY && k !== 'search.mode_upgrade_notice_shown');
if (toRemove.length === 0) {
console.log('No search.* overrides set. Mode bundle is the only voice.');
return;
}
if (dryRun) {
console.log(`--source ${dryRunSource} (dry run). Would unset ${toRemove.length} key(s):`);
for (const k of toRemove) console.log(` - ${k}`);
console.log(`No changes written. Re-run with --reset to apply.`);
return;
}
let deleted = 0;
for (const k of toRemove) {
const n = await engine.unsetConfig(k);
deleted += n;
}
console.log(`Reset complete. Unset ${deleted} key(s):`);
for (const k of toRemove) console.log(` - ${k}`);
console.log(`Mode bundle is now the only voice. Verify with: gbrain search modes`);
return;
}
// Default: read-only dashboard.
const report = await buildModesReport(engine);
if (json) {
console.log(JSON.stringify(report, null, 2));
} else {
console.log(formatModesText(report));
}
}
async function runStatsSubcommand(engine: BrainEngine, args: string[]): Promise<void> {
const json = args.includes('--json');
const daysIdx = args.indexOf('--days');
const days = daysIdx !== -1 ? parseInt(args[daysIdx + 1], 10) : 7;
const stats = await readSearchStats(engine, { days: Number.isFinite(days) ? days : 7 });
// v0.40.4 — graph_signals section. Sourced from:
// 1. config: search.graph_signals (or mode bundle default) for the
// on/off status.
// 2. JSONL audit: graph-signals-failures-*.jsonl for the error count.
//
// Fire-rate metrics (adjacency_fires, cross_source_fires,
// session_demotions) require telemetry table writes from the
// applyGraphSignals onMeta callback — wired in a v0.41+ follow-up
// (T-todo-2 calibration wave). For now: status + error count.
const gsSection = await readGraphSignalsStats(engine, Number.isFinite(days) ? days : 7);
if (json) {
console.log(JSON.stringify({
schema_version: 2,
...stats,
graph_signals: gsSection,
_meta: {
metric_glossary: {
cache_hit_rate: 'cache_hits / (cache_hits + cache_misses) — fraction of searches that reused a recent answer instead of running fresh',
avg_results: 'mean number of result rows returned per search call',
avg_tokens: 'mean estimated tokens in the returned chunk text (char/4 heuristic)',
total_budget_dropped: 'sum of results dropped because the call exceeded its tokenBudget',
graph_signals_enabled: 'whether graph_signals is on for the active mode (or via search.graph_signals override)',
graph_signals_failures_count: 'count of fail-open events in the JSONL audit over the window',
},
},
}, null, 2));
return;
}
console.log(`Search stats over the last ${stats.window_days} days:`);
console.log('');
console.log(` Total searches: ${stats.total_calls}`);
if (stats.total_calls === 0) {
console.log('');
console.log('No telemetry recorded yet. Run a few `gbrain query` calls and re-check.');
// Still print the graph-signals section since failures are tracked
// independently of the search_telemetry table.
if (gsSection.enabled || gsSection.failures_count > 0) {
console.log('');
printGraphSignalsSection(gsSection);
}
return;
}
const hitRatePct = (stats.cache_hit_rate * 100).toFixed(1);
console.log(` Cache hit rate: ${hitRatePct}% (${stats.cache_hits} hit / ${stats.cache_misses} miss)`);
console.log(` (fraction of searches that reused a recent answer)`);
console.log(` Avg results returned: ${stats.avg_results.toFixed(1)}`);
console.log(` Avg tokens delivered: ${stats.avg_tokens.toFixed(0)} (char/4 heuristic)`);
console.log(` Budget drops total: ${stats.total_budget_dropped}`);
// T7 — rank-1 match-quality drift signal. Watch for avg drifting DOWN.
if (stats.avg_rank1_score !== null && stats.rank1_count > 0) {
const d = stats.rank1_distribution;
console.log(` Avg rank-1 score: ${stats.avg_rank1_score.toFixed(3)} (${stats.rank1_count} samples; <0.6:${d.lt_solid} 0.6-0.85:${d.solid} >=0.85:${d.high})`);
console.log(` (top-result match quality; a downward drift = retrieval regressing)`);
}
console.log('');
console.log(' Mode distribution:');
for (const [m, c] of Object.entries(stats.mode_distribution).sort((a, b) => b[1] - a[1])) {
const pct = ((c / stats.total_calls) * 100).toFixed(1);
console.log(` ${m.padEnd(14)} ${c} (${pct}%)`);
}
console.log('');
console.log(' Intent distribution:');
for (const [i, c] of Object.entries(stats.intent_distribution).sort((a, b) => b[1] - a[1])) {
const pct = ((c / stats.total_calls) * 100).toFixed(1);
console.log(` ${i.padEnd(14)} ${c} (${pct}%)`);
}
if (stats.oldest_seen || stats.newest_seen) {
console.log('');
console.log(` Window: ${stats.oldest_seen ?? '?'}${stats.newest_seen ?? '?'}`);
}
console.log('');
printGraphSignalsSection(gsSection);
}
interface GraphSignalsStatsSection {
enabled: boolean;
source: 'config' | 'mode_default';
failures_count: number;
/** Failure-reason breakdown across the window (truncated to top reasons). */
failures_by_reason: Record<string, number>;
}
async function readGraphSignalsStats(engine: BrainEngine, days: number): Promise<GraphSignalsStatsSection> {
// Resolve graph_signals on/off. Mirrors the resolution chain in
// src/commands/doctor.ts:checkGraphSignalsCoverage.
// v0.40.4 codex F1: case-insensitive + trim parity with
// loadOverridesFromConfig (mode.ts). Without this, search-stats would
// silently report the opposite of what the parser actually enables on
// values like 'TRUE' or 'True'.
const cfg = await engine.getConfig('search.graph_signals').catch(() => null);
let enabled: boolean;
let source: 'config' | 'mode_default';
if (cfg !== null && cfg !== undefined) {
const v = cfg.trim().toLowerCase();
enabled = v === 'true' || v === '1';
source = 'config';
} else {
const modeRaw = await engine.getConfig('search.mode').catch(() => null);
const modeVal = typeof modeRaw === 'string' ? modeRaw.trim().toLowerCase() : '';
const mode = modeVal === 'conservative' || modeVal === 'tokenmax' ? modeVal : 'balanced';
enabled = mode !== 'conservative';
source = 'mode_default';
}
let failures_count = 0;
const failures_by_reason: Record<string, number> = {};
try {
const { readRecentGraphSignalsFailures } = await import('../core/search/graph-signals.ts');
const events = readRecentGraphSignalsFailures(days);
failures_count = events.length;
// The failure event schema has error_summary (not a reason field) —
// bucket by the first word of the summary so operators see e.g.
// "ECONNREFUSED" / "timeout" / "permission" at a glance.
for (const e of events) {
const firstWord = (e.error_summary ?? '').split(/[\s:]+/)[0]?.slice(0, 32) || 'unknown';
failures_by_reason[firstWord] = (failures_by_reason[firstWord] ?? 0) + 1;
}
} catch {
// Audit reader is best-effort. Missing module / corrupt files →
// count stays 0, search-stats still renders.
}
return { enabled, source, failures_count, failures_by_reason };
}
function printGraphSignalsSection(gs: GraphSignalsStatsSection): void {
console.log(' Graph signals:');
const sourceLabel = gs.source === 'config' ? 'config override' : 'mode default';
console.log(` enabled: ${gs.enabled} (${sourceLabel})`);
if (gs.failures_count === 0) {
console.log(' failures: 0 (fail-open events in window)');
} else {
console.log(` failures: ${gs.failures_count} fail-open event(s)`);
const top = Object.entries(gs.failures_by_reason)
.sort((a, b) => b[1] - a[1])
.slice(0, 3);
for (const [reason, count] of top) {
console.log(` ${reason.padEnd(20)} ${count}`);
}
}
if (!gs.enabled && gs.failures_count > 0) {
console.log(` note: failures observed but graph_signals currently off — historical events`);
}
}
interface TuneRecommendation {
knob: string;
current: unknown;
suggested: unknown;
reason: string;
apply_command: string;
}
async function runTuneSubcommand(engine: BrainEngine, args: string[]): Promise<void> {
const json = args.includes('--json');
const apply = args.includes('--apply');
const modeInput = await loadSearchModeConfig(engine);
const resolved = resolveSearchMode(modeInput);
const stats = await readSearchStats(engine, { days: 7 });
const recs: TuneRecommendation[] = [];
// Recommendation 1: low call volume → no data yet.
if (stats.total_calls < 20) {
if (json) {
console.log(JSON.stringify({
schema_version: 2,
status: 'insufficient_data',
total_calls: stats.total_calls,
recommendations: [],
message: 'Not enough search activity in the last 7 days to tune. Run `gbrain search stats` after some real usage.',
}, null, 2));
return;
}
console.log('Not enough search activity in the last 7 days to tune.');
console.log(`Total searches: ${stats.total_calls} (need >= 20 for confident recommendations).`);
console.log('Run a few `gbrain query` calls, then re-run `gbrain search tune`.');
return;
}
// Recommendation 2: budget pressure under conservative.
if (resolved.resolved_mode === 'conservative' && stats.total_calls > 0) {
const dropPctPerCall = stats.total_budget_dropped / stats.total_calls;
if (dropPctPerCall > 2) {
recs.push({
knob: 'search.mode',
current: 'conservative',
suggested: 'balanced',
reason: `Avg ${dropPctPerCall.toFixed(1)} results dropped per search by the 4K budget. Consider balanced (12K budget) or raise search.tokenBudget.`,
apply_command: 'gbrain config set search.mode balanced',
});
}
}
// Recommendation 3: high cache hit rate → bump similarity threshold.
if (stats.cache_hit_rate > 0.85 && stats.cache_hits + stats.cache_misses > 50) {
recs.push({
knob: 'search.cache.similarity_threshold',
current: resolved.cache_similarity_threshold,
suggested: 0.94,
reason: `Cache hit rate is ${(stats.cache_hit_rate * 100).toFixed(1)}%. You can raise similarity threshold to 0.94 for tighter freshness at small recall cost.`,
apply_command: 'gbrain config set search.cache.similarity_threshold 0.94',
});
}
// Recommendation 4: tokenmax + Haiku subagent.
const subagentModel = await engine.getConfig('models.tier.subagent');
if (resolved.resolved_mode === 'tokenmax' && subagentModel && /haiku/i.test(subagentModel)) {
recs.push({
knob: 'search.mode',
current: 'tokenmax',
suggested: 'balanced',
reason: `Subagent tier is Haiku but mode is tokenmax. LLM expansion adds ~50ms + ~1¢ per query. Balanced cuts that cost without losing intent weighting or cache.`,
apply_command: 'gbrain config set search.mode balanced',
});
}
// Recommendation 5: cache disabled but available — fix the free win.
if (!resolved.cache_enabled && stats.total_calls > 5) {
recs.push({
knob: 'search.cache.enabled',
current: false,
suggested: true,
reason: 'Cache is disabled but mode bundles enable it by default. Cache is a free win (zero LLM cost, big latency drop on repeat queries).',
apply_command: 'gbrain config unset search.cache.enabled',
});
}
if (json) {
console.log(JSON.stringify({
schema_version: 2,
status: recs.length === 0 ? 'no_recommendations' : 'has_recommendations',
total_calls: stats.total_calls,
cache_hit_rate: stats.cache_hit_rate,
active_mode: resolved.resolved_mode,
recommendations: recs,
applied: apply ? recs.map(r => r.apply_command) : [],
_meta: {
metric_glossary: {
cache_hit_rate: 'cache_hits / (cache_hits + cache_misses)',
total_calls: 'total searches recorded in the last 7 days',
},
},
}, null, 2));
if (apply) {
for (const r of recs) {
await maybeApplyRecommendation(engine, r);
}
}
return;
}
console.log(`Search tune (last 7 days, active mode: ${resolved.resolved_mode}):`);
console.log('');
if (recs.length === 0) {
console.log(' No recommendations. Your search config looks well-tuned.');
return;
}
for (let i = 0; i < recs.length; i++) {
const r = recs[i];
console.log(` ${i + 1}. ${r.knob}: ${String(r.current)}${String(r.suggested)}`);
console.log(` ${r.reason}`);
console.log(` Apply: ${r.apply_command}`);
console.log('');
}
if (apply) {
console.log('Applying recommendations:');
const reverts: string[] = [];
for (const r of recs) {
await maybeApplyRecommendation(engine, r);
console.log(` ✓ ${r.apply_command}`);
reverts.push(buildRevertCommand(r));
}
console.log('');
console.log('To revert these changes:');
for (const cmd of reverts) console.log(` ${cmd}`);
} else {
console.log('Run `gbrain search tune --apply` to apply these changes automatically.');
}
}
async function maybeApplyRecommendation(engine: BrainEngine, r: TuneRecommendation): Promise<void> {
// The apply command is the canonical paste-ready string; here we
// re-parse it to call setConfig / unsetConfig directly so the call
// happens in-process.
const parts = r.apply_command.split(/\s+/);
if (parts[0] !== 'gbrain' || parts[1] !== 'config') return;
if (parts[2] === 'set' && parts.length === 5) {
await engine.setConfig(parts[3], parts[4]);
} else if (parts[2] === 'unset' && parts.length === 4) {
await engine.unsetConfig(parts[3]);
}
}
function buildRevertCommand(r: TuneRecommendation): string {
const parts = r.apply_command.split(/\s+/);
if (parts[2] === 'set') {
return `gbrain config set ${parts[3]} ${String(r.current)}`;
} else if (parts[2] === 'unset') {
return `gbrain config set ${parts[3]} ${String(r.current)}`;
}
return r.apply_command;
}
const USAGE = `Usage: gbrain search <modes|stats|tune> [flags]
Subcommands:
modes [--json] Show active mode, bundles, and per-knob source.
modes --reset Clear all search.* overrides (mode bundle wins).
modes --source <mode> Dry-run: list what --reset would change.
stats [--days N] [--json] Cache hit rate, intent mix, budget pressure.
tune [--apply] [--json] Print recommendations; --apply mutates config.
Examples:
gbrain search modes
gbrain search modes --reset
gbrain search stats --days 30 --json
gbrain search tune
gbrain search tune --apply
`;
export async function runSearch(engine: BrainEngine, args: string[]): Promise<void> {
const sub = args[0];
const rest = args.slice(1);
if (!sub || sub === '--help' || sub === '-h') {
console.log(USAGE);
return;
}
switch (sub) {
case 'modes':
await runModesSubcommand(engine, rest);
return;
case 'stats':
await runStatsSubcommand(engine, rest);
return;
case 'tune':
await runTuneSubcommand(engine, rest);
return;
default:
console.error(`Unknown subcommand: ${sub}`);
console.error(USAGE);
process.exit(1);
}
}
/**
* `gbrain search modes` is read-only — no DB connection strictly required
* for the bundle display IF the engine is given. The dispatch in cli.ts
* adds 'search' to its dispatch table so the engine connects normally;
* this export is here so future no-engine modes (e.g. `gbrain search --help`
* without an engine) could route through it cleanly.
*/
export const _exports_for_test = {
buildModesReport,
formatModesText,
maybeApplyRecommendation,
buildRevertCommand,
};
// Suppress unused-export TS warning — these are intentionally retained for
// downstream callers (cli.ts dispatch / future skill linkage).
void DEFAULT_SEARCH_MODE;