Files
gbrain/test/e2e/engine-parity.test.ts
T
172b55ba9d v0.22.0 feat: source-aware search ranking — curated pages win, swamp dampened (#439)
* feat(search): add exclude_slug_prefixes + include_slug_prefixes to SearchOpts

The two new fields plumb prefix-based hard-exclude through the search API.
exclude_slug_prefixes is additive over the engine's default hard-exclude set
(test/, archive/, attachments/, .raw/) and the GBRAIN_SEARCH_EXCLUDE env var.
include_slug_prefixes subtracts entries from the resolved set so callers can
opt back into directories that are hidden by default.

Stand-alone change — no engine wiring yet (lands in subsequent commits).

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

* feat(search): source-boost + SQL ranking helpers (no engine wiring yet)

Two new modules + unit tests. Pure functions, zero engine dependencies.

source-boost.ts:
  - DEFAULT_SOURCE_BOOSTS map (originals/ 1.5, concepts/ 1.3, writing/ 1.4,
    people/ 1.2, daily/ 0.8, media/x/ 0.7, wintermute/chat/ 0.5, etc.) —
    grounded in the composition of the canonical brain.
  - DEFAULT_HARD_EXCLUDES = ['test/', 'archive/', 'attachments/', '.raw/'].
  - GBRAIN_SOURCE_BOOST + GBRAIN_SEARCH_EXCLUDE env-var parsers, malformed
    entries skipped silently.
  - resolveBoostMap / resolveHardExcludes merge defaults + env + caller opts.

sql-ranking.ts:
  - buildSourceFactorCase emits a CASE expression for the source factor.
    Returns literal '1.0' when detail==='high' so temporal queries bypass
    source-boost (matches the COMPILED_TRUTH_BOOST gate in hybrid.ts).
    Prefixes sorted by length desc so longest-match wins.
  - buildHardExcludeClause emits NOT (col LIKE 'p1%' OR col LIKE 'p2%').
    NOT a NOT LIKE ALL/ANY array — those quantifiers don't express
    set-exclusion correctly for multi-pattern LIKE.
  - LIKE meta-character escape covers all three: %, _, AND \. Backslash
    coverage matters because it's Postgres LIKE's default escape char —
    a literal backslash in a user env prefix would otherwise be
    interpreted as 'escape the next char' and silently match wrong rows.
  - SQL string literals get single-quote doubling so injection-style
    inputs render as inert text inside the quoted string.

39 unit tests cover escape behavior, longest-prefix-match, detail-gate
bypass, malformed env, factor=0 (legal), negative-factor rejection,
SQL-injection-as-literal, and resolver merge semantics.

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

* test(search): E2E coverage for source-boost, hard-exclude, engine parity

search-swamp.test.ts: reproduces the v3-plan headline case. Seeds a
curated originals/talks/article-outline-fat-code page against two
wintermute/chat/ pages stuffed with 'fat code thin harness' repetitions.
Asserts the article wins both keyword and vector ranking, and that
detail=high lets the chat swamp re-surface (temporal-query workflow
preserved). Also asserts source_id passes through the two-stage CTE.

search-exclude.test.ts: verifies test/ + archive/ pages are hidden by
default, that include_slug_prefixes opts back in, and that
exclude_slug_prefixes adds to defaults.

engine-parity.test.ts: codex flagged that searchKeyword's structural
behavior differs between engines (Postgres ranks pages then picks best
chunk; PGLite returns chunks directly). Without parity coverage the fix
could pass on PGLite and silently fail on Postgres. Seeds identical
corpus into both engines, runs identical queries, asserts top-result +
result-set match. Includes a vector-search parity case and a hard-exclude
parity case. Skips gracefully when DATABASE_URL is unset, per the
CLAUDE.md E2E lifecycle pattern.

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

* feat(search): wire source-boost into v0.21.0 chunk-grain searchKeyword + searchKeywordChunks + two-stage searchVector

Layers source-aware ranking on top of v0.21.0's Cathedral II
chunk-grain FTS architecture, in both Postgres and PGLite engines.

postgres-engine.ts:
  - searchKeyword (chunk-grain CTE → DISTINCT ON page dedup): the inner
    ranked_chunks CTE multiplies ts_rank by the source-factor CASE
    expression, hard-exclude prefixes (test/, archive/, attachments/,
    .raw/ by default + env + caller) become a NOT-LIKE OR-chain on
    the WHERE clause, language/symbol-kind filters preserved.
  - searchKeywordChunks (chunk-grain anchor primitive used by two-pass
    Layer 7): same source-boost treatment so the anchor pool that
    feeds two-pass retrieval is also dampened on chat/daily/x dirs.
  - searchVector becomes a two-stage CTE: inner CTE keeps pure
    HNSW ORDER BY (folding source-boost into it would force a
    sequential scan over every chunk), outer SELECT re-ranks by
    raw_score × source-factor. innerLimit scales with offset to
    preserve pagination contract. p.source_id passes through
    inner→outer for v0.18 multi-source callers.
  - All three methods stay inside sql.begin + SET LOCAL
    statement_timeout from v0.19+ (transaction-scoped GUC; bare SET
    leaks onto pooled connections, documented DoS vector).

pglite-engine.ts: mirrors the same three methods. Same SQL shape,
same source-factor + hard-exclude. Two-stage CTE also lifts stale-flag
computation into the outer SELECT (it referenced p.updated_at which
now lives only inside the inner CTE).

Detail-gate (`detail !== 'high'`) inherited from buildSourceFactorCase
... temporal queries bypass source-boost so chat surfaces normally for
date-framed lookups. Same gate pattern as the existing
COMPILED_TRUTH_BOOST in hybrid.ts.

Tests: 142 pass across pglite-engine, postgres-engine, sql-ranking,
search-swamp E2E, search-exclude E2E.

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

* docs: update project documentation for v0.22.0 (rebased onto v0.21.0 master)

CHANGELOG: new v0.22.0 entry above v0.21.0 (Cathedral II). Headline
positions v0.22.0 as additive on top of v0.21.0's two-pass retrieval
... different mechanism, +3.3pts top-1 / -3.3pts swamp on the new
Cat 13b benchmark in the sibling gbrain-evals repo.

CLAUDE.md:
  - postgres-engine.ts entry mentions all three updated methods
    (searchKeyword, searchKeywordChunks, searchVector) and the
    two-stage CTE for searchVector specifically.
  - pglite-engine.ts entry parallels the Postgres notes.
  - src/core/search/ entry calls out source-aware ranking +
    hard-exclude defaults + detail-gate parity with COMPILED_TRUTH_BOOST.
  - Added entries for src/core/search/source-boost.ts and
    src/core/search/sql-ranking.ts in the Key Files section.
  - Added test/sql-ranking.test.ts and the three new E2E test
    files (search-swamp, search-exclude, engine-parity) to the
    test listings.

README.md: SEARCH PIPELINE diagram in the "many strategies in concert"
section gains two lines for source-aware ranking and hard-exclude
filtering.

VERSION: 0.21.0 → 0.22.0.

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

* fix(tests): typecheck + Postgres minions-shell env-var setup

Two test fixes uncovered while running the full bun run test + E2E
suite at zero defects.

test/e2e/engine-parity.test.ts: BrainEngine was being imported from
src/core/types.ts but it's actually exported from src/core/engine.ts;
the import was silently working under bare `bun test` but failing
typecheck. Fixed the import path and annotated 6 implicit-any
SearchResult callbacks. (No behavior change ... typecheck only.)

test/e2e/minions-shell.test.ts: the Postgres minions-shell test was
missing the `GBRAIN_ALLOW_SHELL_JOBS=1` env-var setup that the
PGLite sibling test in test/e2e/minions-shell-pglite.test.ts already
has. Without it the shell handler short-circuits and the job lands
in `dead`, not `completed`. The env var is the operator-trust gate
for the shell handler ... separate from the trusted-add
allowProtectedSubmit flag. Adding the same beforeAll/afterAll
setup-and-restore pattern from the PGLite sibling brings the test
to green.

Both bugs were latent on master ... bare `bun test` skipped the
typecheck and the minions-shell E2E was a pre-existing flake
(documented as such in earlier branch summary).

Verified: full unit suite 2714 pass / 0 fail (`bun run test`),
full E2E suite 225 pass / 0 fail across 24 files.

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

* chore: regenerate llms-full.txt for v0.22.0 doc updates

Picks up the v0.22.0 entries added to CLAUDE.md (source-boost.ts,
sql-ranking.ts, three new E2E test files, postgres/pglite engine
search-method updates). The build-llms.test.ts regen-drift guard
was failing because the committed bundle didn't match the current
generator output.

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

* fix(search): adversarial review fixes — detail loose-string + PGLite CTE alias

Two FIXABLE findings from /ship's adversarial subagent pass:

1. **buildSourceFactorCase: tolerate loose-string `detail` over the MCP
   boundary.** TypeScript narrows the typed callers, but agents passing
   JSON across MCP can send `"HIGH"` (uppercase) or `"high "` (trailing
   space). Before this change, those values silently fell through the
   `detail === 'high'` strict-equality check and got boosted ranking
   instead of the temporal bypass — the opposite of what the agent asked
   for. Now the gate normalizes `String(detail).trim().toLowerCase()`
   before comparing. Three new test cases cover `"HIGH"`, `"high "`, and
   `"  High  "`.

2. **PGLite searchVector: alias the hnsw_candidates CTE as `hc` and
   qualify the correlated subquery.** The prior shape had
   `WHERE te.page_id = page_id` in the staleness subquery — unqualified
   `page_id` resolved by lexical-scope fallback to
   `hnsw_candidates.page_id`, but if the inner column is ever renamed or
   the parser changes, it would silently bind to `te.page_id` itself
   (always true) and every result returns `stale=true`. Aliasing the CTE
   as `hc` and qualifying both `hc.page_id` and `hc.slug` (via building
   the source-factor CASE with `'hc.slug'`) eliminates the ambiguity.
   Postgres `searchVector` was already safe — it uses `false AS stale`
   (no correlated subquery) — so no symmetric change needed there.

Three INVESTIGATE findings deferred:
- HNSW + hard-exclude planner behavior on real Postgres (needs EXPLAIN on
  a 50K+ chunk Supabase corpus, not reproducible on PGLite)
- searchKeywordChunks pagination pool growth (would change the v0.21.0
  contract; inherits the original Cathedral II shape)
- resolveBoostMap re-reads process.env per call (cheap, intentional —
  enables mid-process env reload for tuning)

Verified: 137 pass / 0 fail across sql-ranking + pglite-engine +
search-swamp + search-exclude tests.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:35:27 -07:00

228 lines
8.5 KiB
TypeScript

/**
* Engine Parity E2E
*
* Codex flagged that searchKeyword behavior differs structurally between
* the two engines (Postgres uses a CTE that ranks pages then picks best
* chunk; PGLite returns chunks directly). Without verification, source-aware
* ranking could pass on PGLite and silently fail on Postgres.
*
* Strategy: seed identical corpora into both engines, run identical queries,
* assert top-5 slug ordering matches.
*
* Gated by DATABASE_URL — skips gracefully if no real Postgres. Always runs
* the PGLite half so the seed/query path is at least exercised.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
import type { ChunkInput, SearchResult } from '../../src/core/types.ts';
import type { BrainEngine } from '../../src/core/engine.ts';
import { hasDatabase, setupDB, teardownDB, getEngine } from './helpers.ts';
const SKIP_PG = !hasDatabase();
const describeBoth = SKIP_PG ? describe.skip : describe;
function basisEmbedding(idx: number, dim = 1536): Float32Array {
const emb = new Float32Array(dim);
emb[idx % dim] = 1.0;
return emb;
}
interface SeedPage {
slug: string;
type: 'writing' | 'concept' | 'note' | 'person' | 'company';
title: string;
body: string;
embeddingDim: number;
}
const SEED_PAGES: SeedPage[] = [
{
slug: 'originals/talks/article-outline-fat-code',
type: 'writing',
title: 'Fat Code Thin Harness — Part 3',
body: 'fat code thin harness pattern part 3 production case studies',
embeddingDim: 7,
},
{
slug: 'concepts/fat-code-thin-harness',
type: 'concept',
title: 'Fat Code Thin Harness',
body: 'reusable concept fat code thin harness architecture',
embeddingDim: 14,
},
{
slug: 'wintermute/chat/2026-04-15',
type: 'note',
title: '2026-04-15 chat',
body:
'fat code thin harness fat code thin harness discussion went on at length, ' +
'fat code thin harness came up again and again, fat code thin harness fat code thin harness.',
embeddingDim: 8,
},
{
slug: 'wintermute/chat/2026-04-16',
type: 'note',
title: '2026-04-16 chat',
body:
'fat code thin harness once more, fat code thin harness fat code thin harness, ' +
'still talking about fat code thin harness fat code thin harness.',
embeddingDim: 9,
},
{
slug: 'people/example-founder',
type: 'person',
title: 'Example Founder',
body: 'example founder unrelated content for distraction',
embeddingDim: 50,
},
];
async function seedEngine(eng: BrainEngine) {
for (const p of SEED_PAGES) {
await eng.putPage(p.slug, {
type: p.type,
title: p.title,
compiled_truth: p.body,
timeline: '',
});
const chunks: ChunkInput[] = [
{
chunk_index: 0,
chunk_text: p.body,
chunk_source: 'compiled_truth',
embedding: basisEmbedding(p.embeddingDim),
token_count: p.body.split(/\s+/).length,
},
];
await eng.upsertChunks(p.slug, chunks);
}
}
const QUERIES = [
'fat code thin harness',
'fat code thin harness part 3',
'fat code production',
];
describeBoth('Engine parity — Postgres vs PGLite', () => {
let pgEngine: BrainEngine;
let pgliteEngine: PGLiteEngine;
beforeAll(async () => {
pgEngine = await setupDB();
await seedEngine(pgEngine);
pgliteEngine = new PGLiteEngine();
await pgliteEngine.connect({});
await pgliteEngine.initSchema();
await seedEngine(pgliteEngine);
}, 90_000);
afterAll(async () => {
await pgliteEngine.disconnect();
await teardownDB();
});
for (const q of QUERIES) {
test(`searchKeyword: top-5 slugs match for "${q}"`, async () => {
const pgResults = await pgEngine.searchKeyword(q, { limit: 5 });
const pgliteResults = await pgliteEngine.searchKeyword(q, { limit: 5 });
const pgSlugs = pgResults.map((r: SearchResult) => r.slug);
const pgliteSlugs = pgliteResults.map((r: SearchResult) => r.slug);
// Top result MUST match (the swamp-resistance guarantee).
expect(pgSlugs[0]).toBe(pgliteSlugs[0]);
// Sets should match (allowing some ordering drift on lower-ranked
// results since FTS rank function differences between engines are
// out of scope for this fix).
expect(new Set(pgSlugs)).toEqual(new Set(pgliteSlugs));
});
}
test('searchVector: top result matches between engines', async () => {
const queryVec = basisEmbedding(7); // article direction
const pgResults = await pgEngine.searchVector(queryVec, { limit: 5 });
const pgliteResults = await pgliteEngine.searchVector(queryVec, { limit: 5 });
expect(pgResults[0]?.slug).toBe(pgliteResults[0]?.slug);
});
test('hard-exclude is consistent across engines', async () => {
// Both engines should hide test/ pages by default; both should opt
// them back in via include_slug_prefixes.
await pgEngine.putPage('test/parity-fixture', {
type: 'note',
title: 'parity test fixture',
compiled_truth: 'parity test fixture content',
timeline: '',
});
await pgEngine.upsertChunks('test/parity-fixture', [{
chunk_index: 0,
chunk_text: 'parity test fixture content',
chunk_source: 'compiled_truth',
embedding: basisEmbedding(20),
token_count: 5,
}] satisfies ChunkInput[]);
await pgliteEngine.putPage('test/parity-fixture', {
type: 'note',
title: 'parity test fixture',
compiled_truth: 'parity test fixture content',
timeline: '',
});
await pgliteEngine.upsertChunks('test/parity-fixture', [{
chunk_index: 0,
chunk_text: 'parity test fixture content',
chunk_source: 'compiled_truth',
embedding: basisEmbedding(20),
token_count: 5,
}] satisfies ChunkInput[]);
const pgDefault = await pgEngine.searchKeyword('parity test fixture');
const pgliteDefault = await pgliteEngine.searchKeyword('parity test fixture');
expect(pgDefault.map((r: SearchResult) => r.slug)).not.toContain('test/parity-fixture');
expect(pgliteDefault.map((r: SearchResult) => r.slug)).not.toContain('test/parity-fixture');
const pgOptIn = await pgEngine.searchKeyword('parity test fixture', {
include_slug_prefixes: ['test/'],
});
const pgliteOptIn = await pgliteEngine.searchKeyword('parity test fixture', {
include_slug_prefixes: ['test/'],
});
expect(pgOptIn.map((r: SearchResult) => r.slug)).toContain('test/parity-fixture');
expect(pgliteOptIn.map((r: SearchResult) => r.slug)).toContain('test/parity-fixture');
});
test('detail=high produces a different ranking than default on at least one engine', async () => {
// Source-boost gates on `detail !== 'high'`. If the gate works on both
// engines, the ordering for `detail=high` should differ from default in
// any case where the swamp / curated pages have different raw scores.
//
// Postgres's CTE ranks pages then picks best chunk; ts_rank normalizes
// by doc length so chat pages don't always swamp at the page level.
// PGLite scores chunks directly — chat chunks beat article chunks on
// raw ts_rank. The two engines need different parity contracts here.
//
// Common assertion that holds on both: detail=high must include the
// chat pages in its result set (they're not filtered by detail), and
// the result set should not be identical to default-detail (the boost
// must be doing _something_ visible).
const pgDefault = await pgEngine.searchKeyword('fat code thin harness', { limit: 5 });
const pgHigh = await pgEngine.searchKeyword('fat code thin harness', { detail: 'high', limit: 5 });
const pgliteDefault = await pgliteEngine.searchKeyword('fat code thin harness', { limit: 5 });
const pgliteHigh = await pgliteEngine.searchKeyword('fat code thin harness', { detail: 'high', limit: 5 });
// Chat pages must be present in detail=high results on both engines.
expect(pgHigh.some((r: SearchResult) => r.slug.startsWith('wintermute/chat/'))).toBe(true);
expect(pgliteHigh.some((r: SearchResult) => r.slug.startsWith('wintermute/chat/'))).toBe(true);
// The boost must be doing something — at least one engine's ordering
// should change between default and detail=high.
const pgChanged = pgDefault.map((r: SearchResult) => r.slug).join(',') !== pgHigh.map((r: SearchResult) => r.slug).join(',');
const pgliteChanged = pgliteDefault.map((r: SearchResult) => r.slug).join(',') !== pgliteHigh.map((r: SearchResult) => r.slug).join(',');
expect(pgChanged || pgliteChanged).toBe(true);
});
});