Commit Graph
5 Commits
Author SHA1 Message Date
099d9a8f55 v0.42.34.0 feat(search): typed-edge relational retrieval — relationship questions get relationship answers (#1959)
* feat(search): deterministic relational-query parser

Pure, ReDoS-bounded parser that detects relationship queries ("who invested
in X", "who at X works on Y", "who introduced me to X", "what connects A and
B") and maps them to typed edges. Schema-pack-extensible vocab with subset
validation against the link types ingest produces, so query-side and
ingest-side relation vocabularies can't drift. No-match / pronoun-seed /
adjacency guards keep it precision-first (a candidate only; the arm fires
only when a real seed also resolves).

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

* feat(engine): relationalFanout typed-edge fan-out (both engines)

Generalizes traversePaths to a SEED ARRAY, aggregating to ranked NODES
(shortest hop, edge-richness count, via-link-types, shortest connecting
path, canonical chunk id) instead of edges. Within-source traversal (never
crosses a source boundary even across a federated scope), link_source=
'mentions' excluded by default, deleted_at filtered at seed + every neighbor
+ every node, bounded depth (<=3) + candidate cap. Adds RelationalFanoutRow
/ RelationalFanoutOpts + the relational SearchResult/SearchOpts fields to
types.

Lands in lockstep in postgres + pglite engines, pinned by a DATABASE_URL-
gated parity block in engine-parity.test.ts; a PGLite unit test exercises
the SQL (typed-edge filter, mentions exclusion, deleted_at, canonical chunk,
multi-seed connects, determinism) in default CI.

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

* feat(search): relational recall arm + federation key hardening

Wires edge-derived candidates into bare hybridSearch as a FOURTH RRF arm
(relational-recall.ts): parse the original query -> scope-aware,
confidence-gated seed resolution (drops fallback_slugify; never traverses
from a guess) -> relationalFanout -> batch-hydrate, reinforcing each page's
REAL canonical chunk (page-level key for chunkless entity pages) ->
--explain attribution + fail-open audit row. Text-only (no-op in image
mode); pure no-op for non-relational queries; rides every downstream stage
(cosine, post-fusion boosts, dedup, reranker, autocut, token budget).

Mode wiring: relationalRetrieval + relational_retrieval_depth knobs
(conservative off; balanced/tokenmax on; depth 2), per-call thread-through
in both bare + cached paths, KNOBS_HASH_VERSION 9->10 (rel=/reld=), config
keys, modes-dashboard descriptions, and a `relational` param on the query op.

Federation hardening (structural, engine-wide): the RRF/dedup key now
carries source_id via a shared rrfKey() (fixes a latent cross-source
collapse where same-slug pages in different sources merged), and the query
cache scopes by a canonical source-set key (cacheScopeKey) so a federated
read can't be served a single-source row.

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

* feat(eval): relational benchmark + recall@k harness metrics

NamedThingBench harness gains recall@k / recall@10 (the relational headline
metric) on QuestionResult + FamilyReport, plus typed seed/linkTypes/kind on
NamedThingQuestion so the graph-relationship family is machine-checkable.

Adds the relational benchmark corpus (test/fixtures/retrieval-quality/
relational/): a small entity graph whose answers are LEXICALLY UNRECOVERABLE
— every page body is generic and never names the entity it relates to, so
only the typed edge connects query to answer. corpus.ts is the canonical
source for both the seed loader and the 38-question gold set; relational.jsonl
is generated from it (a drift test pins them equal).

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

* feat(eval): relational A/B proof + arm fires on all retrieval paths

Fixes the integration bug the eval caught: the relational arm was only
injected on the main RRF path, so it silently did nothing whenever vector
was unavailable — no embedding provider configured (the default in many
deployments) OR embed failure. The arm is now built once and fused via RRF
on ALL THREE hybridSearch return paths (no-embedding-provider, embed-failed
keyword fallback, main path). Without this it would have been dead in
exactly the setups that most need it.

Adds `gbrain eval retrieval-quality --ab-relational`: runs the gold set
twice (arm off vs on) in a fixed mode and reports the graph-relationship
recall@10 lift + Hit@3 + latency add. The CI A/B test pins the headline
result — recall@10 jumps from <25% (lexically unrecoverable) to >75% with
the arm on — and a non-relational query returns identical results arm-on vs
off (the no-op / no-regression gate).

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

* chore: bump version and changelog (v0.42.34.0)

Relational retrieval feature: typed-edge recall arm + federation key
hardening. Also updates the KNOBS_HASH_VERSION 9→10 assertions across the
remaining search test files (the bump invalidates relational-off cache rows).

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

* docs: document typed-edge relational retrieval (v0.42.34.0)

CLAUDE.md Search Mode: add relationalRetrieval to the knob table, the
knobs_hash v=9→10 note, and a relational-retrieval summary. RETRIEVAL.md:
add the relational recall arm to the pipeline diagram. Regenerate llms
bundles (build:llms).

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

* fix(test): size relational fixtures to the actual embedding-column dim

CI shard runs with the ZeroEntropy gateway default (1280-d), but the
relational test fixtures hardcoded 1536-d embeddings, so chunk inserts were
rejected with "expected 1280 dimensions, not 1536" (CheckExpectedDim) — the
`test (6)` shard + `test-status` failures. The column width tracks the
configured gateway default and can shift with shard order, so fixtures now
probe `content_chunks.embedding`'s actual `atttypmod` after initSchema and
size embeddings to it (the pglite-engine.test.ts pattern), via a shared
`probeEmbeddingDim` helper. Verified passing at a forced 1280-d column.

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-06-07 22:05:31 -07:00
f7f8512b14 v0.42.28.0 fix(engine): batch inserts use jsonb_to_recordset, not text[] array literals (#1861) (#1927)
* fix(engine): batch inserts use jsonb_to_recordset, not text[] array literals (#1861)

addLinksBatch/addTimelineEntriesBatch/addTakesBatch passed free text through
unnest(${arr}::text[]); postgres.js serialized it to a Postgres text[] literal
that array_in rejected ("malformed array literal") on calendar/Zoom context,
aborting the whole `extract links --stale` sweep. Bind the batch as one JSONB
doc via jsonb_to_recordset(($1::jsonb)->'rows') through the audited
executeRawJsonb contract instead. Shared row builders (src/core/batch-rows.ts)
keep both engines byte-identical; NUL is stripped only from free-text body
fields (context/summary/detail/claim), while identity/security fields
(slugs/source_ids/holder/kind/dates) still reject NUL. addTakesBatch is now
batchRetry-wrapped ('addTakesBatch' audit site) and its BrainEngine signature
takes BatchOpts. Scalar addLink context is NUL-stripped too.

Regression tests on both engines: PGLite always-on poison/NUL/parity suite +
DATABASE_URL-gated Postgres lane (the engine that actually crashed).

* test: make "no Anthropic key" tests hermetic via withoutAnthropicKey

hasAnthropicKey() reads both ANTHROPIC_API_KEY and ~/.gbrain config; tests that
only deleted the env var fired a real LLM call on configured machines (warning
flipped NO_ANTHROPIC_API_KEY -> LLM_OUTPUT_NOT_JSON). New test/helpers/no-anthropic-key.ts
neutralizes both sources (env + GBRAIN_HOME temp dir) for the duration of the call.
Refactors the five no-key tests in think-pipeline + takes-mcp-allowlist to use it,
including two that previously passed only by luck of the live LLM output.

* chore: docs + version bump (v0.42.28.0)

KEY_FILES.md/RETRIEVAL.md describe the jsonb_to_recordset batch path; TODOS.md
files the #1861 follow-ups (element-isolation, remaining ::text[] sites, shared
SQL-string hoist, batch-insert edge-case tests). CHANGELOG + VERSION + package.json
to 0.42.28.0.

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

* docs: sync TESTING.md batch-insert references for v0.42.28.0

The #1861 fix migrated links/timeline/takes batch inserts from
unnest(::text[]) to jsonb_to_recordset. Update the stale "postgres-js
unnest() binding" note and add the two new poison-regression test files
(test/links-timeline-jsonb-poison.test.ts PGLite half,
test/e2e/jsonb-batch-poison-postgres.test.ts Postgres lane) to the inventory.

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

* fix(sql-query): reject top-level array jsonb params in executeRawJsonb (#1861 P2a)

The "no top-level array" rule was only a comment. A bare JS array bound to a
$N::jsonb position can serialize as a Postgres array literal (not jsonb) through
postgres.js, silently re-entering the "malformed array literal" class #1861 just
escaped. executeRawJsonb now throws a clear error steering callers to the
{ rows: [...] } object wrapper. Verified breaks zero call sites (all pass objects
or null). Codex adversarial P2a; batch-size enforcement (P2b) filed as a TODO.

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-06-07 08:05:34 -07:00
bea2d3e6c9 v0.42.13.0 fix(search): archive/ content findable by default, demoted not hard-excluded (#1777) (#1797)
* fix(search): archive/ findable by default — demote not hard-exclude (#1777)

Move archive/ out of DEFAULT_HARD_EXCLUDES into a 0.5 source-boost demote so
archived historical content is findable by default, ranked below curated
content. Add a hidden_by_search_policy doctor check so the surviving exclude
policy (test/, attachments/, .raw/) is auditable. Bump KNOBS_HASH_VERSION 8->9
so the policy change invalidates archive-excluded query_cache rows.

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

* chore: bump version and changelog (v0.42.13.0)

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

* docs: correct search-exclude.test.ts annotation for archive demote (#1777)

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-06-03 06:28:21 -07:00
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
cdba533a04 v0.36.2.0 feat: ZeroEntropy as default + zero-based README rewrite (#1136)
* feat(dims): OpenAI text-embedding-3 Matryoshka range validation (D13)

dimsProviderOptions now fail-loud at the embed boundary when the
configured embedding_dimensions is outside the model's native range
(1..1536 for -small, 1..3072 for -large). Paste-ready fix hint in the
AIConfigError.fix field. Closes the silent-HTTP-400 path that would
have bit OpenAI-fallback users on v0.36.0.0 ZE-default installs.

16 new test cases in test/ai/dims-openai.test.ts pinning the contract
across native-openai and openai-compatible adapter paths.

* feat(ai): flip defaults to ZeroEntropy zembed-1 1280d + zerank-2 reranker

Default embedding model is now zeroentropyai:zembed-1 at 1280d via
Matryoshka. Real-corpus benchmark: 2.2x faster than OpenAI, 2.6x
cheaper at regular pricing, wins 11/20 head-to-head queries.

1280 is the closest valid ZE Matryoshka step to the prior OpenAI 1536d
default (valid set: 2560/1280/640/320/160/80/40). 1024 (Voyage's step)
is NOT on ZE's list — pinned by AIConfigError fail-loud in dims.ts.

balanced mode bundle now defaults reranker_enabled=true. zerank-2
reshuffles 60% of top-1 results in benchmarks. Missing-key fail-open
contract in src/core/search/rerank.ts handles unauthenticated cases.
Opt out with: gbrain config set search.reranker.enabled false

Existing tests updated (gateway.test.ts, search-mode.test.ts) and a
new test/balanced-reranker-default.test.ts (10 cases) pins the fail-
open invariants.

* feat(retrieval-upgrade): RetrievalUpgradePlanner + interactive prompt UX

New src/core/retrieval-upgrade-planner.ts is the consolidated planner
that computes the brain's pending retrieval-upgrade work (chunker
bumps + ZE switch) in one pass and applies the schema transition +
config updates atomically.

Tagged-union ApplyResult enum (D15): 'applied' | 'skipped_already_
applied' | 'skipped_no_work' | 'declined' | 'planned' | 'failed'.
No string-parsing reasons.

Three config keys (D12): ze_switch_prompt_shown (UI state),
ze_switch_requested (user intent), ze_switch_applied (work done).
Plus ze_switch_previous_snapshot (JSON, full prior config for --undo
per D16) and ze_switch_declined_at (90-day re-ask window).

Schema transition (D18) is atomic: DROP indexes + ALTER COLUMN +
CREATE INDEX inside a single engine.transaction(). HNSW recreation
is part of the same transaction — no silent slow-search window.

C3 eligibility logic: ze_switch_offered iff NOT on ZE + NOT declined
recently + NOT applied + (legacy default OR >100 pages).

C4 cost math: MAX(chunker_pending, dim_pending) not SUM — one
re-embed pass invalidates both surfaces simultaneously.

New src/core/retrieval-upgrade-prompt.ts wires the planner to a
TTY-only interactive prompt with two-line cost split (D10) and
privacy callout for the reranker flip.

Tests: test/retrieval-upgrade-planner.test.ts (24 cases) pins the
state machine. test/asymmetric-encoding-contract.test.ts (6 cases)
pins D17: search read path uses gateway.embedQuery() not embed(),
asserted via __setEmbedTransportForTests mock.

* feat(cli): gbrain ze-switch — manual lever for the ZE switch

New gbrain ze-switch CLI with --dry-run, --json, --resume, --force,
--undo, --non-interactive, --confirm-reembed, --ignore-missing-key
flags. Mirrors the upgrade prompt's UX symmetry: --undo presents a
cost-warning before re-embedding back to the prior width.

src/cli.ts: dispatch case + CLI_ONLY entry. ze-switch owns its own
engine lifecycle (mirrors the doctor pattern).

test/ze-switch-cli.test.ts (11 cases): --help, --dry-run, --json,
--non-interactive, --ignore-missing-key, --resume, --undo,
--confirm-reembed. Uses captureExit harness to test process.exit()
paths without breaking the test process.

* feat(doctor): ze_embedding_health + embedding_width_consistency checks

Two new doctor checks (D-A5):

ze_embedding_health: when embedding_model starts with zeroentropyai:,
verify ZEROENTROPY_API_KEY is set (env or config). Paste-ready setup
hint with the signup URL on failure.

embedding_width_consistency: cross-check that the configured
embedding_dimensions matches the actual vector(N) column width on
content_chunks.embedding. Catches the half-applied switch state
(schema migrated but config write crashed) with a paste-ready
gbrain ze-switch --resume hint.

Wired into runDoctor between reranker_health and the existing
sync_freshness checks. Both checks gracefully no-op on non-ZE
embedding configs.

test/doctor-ze-checks.test.ts (8 cases) pins both checks across
happy + missing-key + missing-config + drift paths. Uses withEnv()
helper to clear ZEROENTROPY_API_KEY for the no-key path so tests
are hermetic against contributor env state.

test/e2e/v0_28_5-fix-wave.test.ts + test/openai-compat-multimodal.test.ts:
updated to explicit-configure the gateway when the test depends on
specific dims that diverge from the v0.36.0.0 default (1280d).

* docs: README zero-based rewrite (884 -> 139 lines) + new docs files

Strip 4 months of accreted "New in v0.X.Y" hero blocks and reorganize
around what gbrain does today. 33 H2s -> 8. The Commands section
(136 lines duplicating gbrain --help) moved out; the 6-table skills
enumeration collapsed to a one-paragraph capability description with
a link to skills/RESOLVER.md.

Hero retains load-bearing facts: OpenClaw + Hermes credit, production
numbers (17,888 pages / 4,383 people / 723 companies), BrainBench
numbers (P@5 49.1% / R@5 97.9% / +31.4 lift), ZE comparison numbers,
30-min install claim. Adds one paragraph announcing the v0.36.0.0 ZE
default with the explicit gbrain config set escape for OpenAI/Voyage
users.

New files:
- docs/INSTALL.md: every install path consolidated (agent platform,
  CLI standalone, MCP server). Thin-client mode covered.
- docs/architecture/RETRIEVAL.md: why the hybrid + graph stack works.
  BrainBench numbers, why each strategy alone fails, the source-aware
  ranking + intent classification + multi-query expansion story.
- docs/ethos/ORIGIN.md: origin story lifted from the old README so
  the front door stays factual + concrete.

test/readme-hero-anchors.test.ts (5 cases) is the D9 regression
guard. Five load-bearing strings: OpenClaw, Hermes, ZE,
production-numbers regex, P@5/R@5. Light anchors that let voice/
structure evolve but block accidental loss of headline facts.

scripts/check-test-real-names.sh: allowlist entries for OpenClaw +
Hermes literals in the anchor test (it explicitly asserts those
strings appear in README).

* chore: bump version and changelog (v0.36.0.0)

ZeroEntropy as the new default for embedding (zembed-1 at 1280d via
Matryoshka) and reranker (zerank-2 cross-encoder, on by default in
balanced mode bundle). README zero-based rewrite (884 -> 139 lines).
3 new docs files. Two new doctor checks. New gbrain ze-switch CLI
with --undo for symmetric reversibility.

skills/migrations/v0.36.0.0.md tells the agent how to surface the
retrieval-upgrade prompt post-upgrade.

llms-full.txt regenerated via bun run build:llms.

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

* fix(docs): scrub Wintermute from RETRIEVAL.md per privacy rule

* chore: rebump version 0.36.0.0 → 0.36.2.0 (queue collision)

Three open PRs were claiming v0.36.0.0 (#1130 skillpack, #1139
hindsight, #1136 this PR). Ship-aware queue allocator says this
branch lands at v0.36.2.0.

Trio audit:
  VERSION       0.36.2.0
  package.json  0.36.2.0
  CHANGELOG     ## [0.36.2.0] - 2026-05-17

Updates: VERSION, package.json, CHANGELOG header + body refs,
README "New default in v0.36.2.0" announcement + credit line,
skills/migrations/v0.36.0.0.md renamed to v0.36.2.0.md with
frontmatter + body refs updated. llms-full.txt regenerated.

* fix(test): pin gateway dim=1536 in cross-file-stateful PGLite tests

CI shard 1 reported 10 failures across `query-cache.test.ts` (6) and
`consolidate-valid-until.test.ts` (4). Both files hardcode 1536-dim
vectors but rely on `PGLiteEngine.initSchema()` to size
`vector(__EMBEDDING_DIMS__)` at the right width.

Root cause: v0.36.2.0 flipped DEFAULT_EMBEDDING_DIMENSIONS from 1536
to 1280 (ZE Matryoshka step). The gateway module is process-singleton;
when ANOTHER test file in the same shard's bun-test process configures
the gateway before us, `pglite-engine.ts:216` reads
`getEmbeddingDimensions() === 1280` and sizes the schema columns at
vector(1280). The hardcoded 1536-dim INSERTs then fail with
"expected 1280 dimensions, not 1536".

Locally these tests pass in isolation because the gateway falls back
through the try/catch at pglite-engine.ts:218 (1536 default). CI runs
multiple test files in one process, so cross-file state poisons the
schema width.

Fix: explicit `resetGateway()` + `configureGateway({embedding_dimensions:
1536, ...})` at the top of `beforeAll`, plus `resetGateway()` in
`afterAll`. Pins the schema width regardless of cross-file state.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:11:02 -07:00