Files
gbrain/test/sql-ranking.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

256 lines
9.2 KiB
TypeScript

import { describe, test, expect } from 'bun:test';
import {
buildSourceFactorCase,
buildHardExcludeClause,
__test__,
} from '../src/core/search/sql-ranking.ts';
import {
DEFAULT_SOURCE_BOOSTS,
DEFAULT_HARD_EXCLUDES,
parseSourceBoostEnv,
parseHardExcludesEnv,
resolveBoostMap,
resolveHardExcludes,
} from '../src/core/search/source-boost.ts';
const { escapeLikePattern, escapeSqlLiteral, buildLikePrefixLiteral } = __test__;
describe('escapeLikePattern', () => {
test('escapes %', () => {
expect(escapeLikePattern('foo%bar')).toBe('foo\\%bar');
});
test('escapes _', () => {
expect(escapeLikePattern('foo_bar')).toBe('foo\\_bar');
});
test('escapes \\ (Postgres LIKE default escape char)', () => {
expect(escapeLikePattern('foo\\bar')).toBe('foo\\\\bar');
});
test('escapes all three together', () => {
expect(escapeLikePattern('a%b_c\\d')).toBe('a\\%b\\_c\\\\d');
});
test('leaves plain strings untouched', () => {
expect(escapeLikePattern('originals/talks/')).toBe('originals/talks/');
});
});
describe('escapeSqlLiteral', () => {
test('doubles single quotes', () => {
expect(escapeSqlLiteral("O'Brien")).toBe("O''Brien");
});
test('handles SQL injection attempts as literal data', () => {
// Classic injection pattern is rendered harmless because the doubled
// quote keeps it inside a string literal in the emitted SQL.
expect(escapeSqlLiteral("'; DROP TABLE pages; --")).toBe("''; DROP TABLE pages; --");
});
});
describe('buildLikePrefixLiteral', () => {
test('produces a quoted LIKE pattern with trailing %', () => {
expect(buildLikePrefixLiteral('originals/')).toBe("'originals/%'");
});
test('escapes meta-chars before adding the trailing %', () => {
// Input contains a literal % that should be escaped, and the trailing
// % we add is the LIKE wildcard.
expect(buildLikePrefixLiteral('weird%path/')).toBe("'weird\\%path/%'");
});
test('escapes single-quote in prefix as SQL literal', () => {
expect(buildLikePrefixLiteral("o'brien/")).toBe("'o''brien/%'");
});
});
describe('buildSourceFactorCase', () => {
test('returns plain 1.0 when detail is "high" (temporal bypass)', () => {
const result = buildSourceFactorCase('p.slug', { 'originals/': 1.5 }, 'high');
expect(result).toBe('1.0');
});
test('temporal bypass tolerates uppercase / whitespace from MCP boundary', () => {
// Agents passing JSON over MCP can send "HIGH" or "high " (trailing
// space). The bypass must catch these — otherwise loose-string callers
// silently get boosted ranking instead of temporal bypass.
const map = { 'originals/': 1.5 };
expect(buildSourceFactorCase('p.slug', map, 'HIGH' as 'high')).toBe('1.0');
expect(buildSourceFactorCase('p.slug', map, 'high ' as 'high')).toBe('1.0');
expect(buildSourceFactorCase('p.slug', map, ' High ' as 'high')).toBe('1.0');
});
test('returns plain 1.0 when boost map is empty', () => {
expect(buildSourceFactorCase('p.slug', {}, 'medium')).toBe('1.0');
});
test('emits a CASE expression for non-high detail', () => {
const result = buildSourceFactorCase('p.slug', { 'originals/': 1.5 }, 'medium');
expect(result).toBe("(CASE WHEN p.slug LIKE 'originals/%' THEN 1.5 ELSE 1.0 END)");
});
test('sorts prefixes by length descending so longest-match wins', () => {
const result = buildSourceFactorCase(
'p.slug',
{ 'media/': 0.9, 'media/articles/': 1.1, 'media/x/': 0.7 },
'medium',
);
// Longest first: media/articles/ (15), media/x/ (8), media/ (6)
const m = result.match(/LIKE '([^']+)%'/g);
expect(m).toEqual([
"LIKE 'media/articles/%'",
"LIKE 'media/x/%'",
"LIKE 'media/%'",
]);
});
test('detail=low and detail=undefined both emit the boost CASE', () => {
const map = { 'originals/': 1.5 };
expect(buildSourceFactorCase('p.slug', map, 'low')).toContain('CASE WHEN');
expect(buildSourceFactorCase('p.slug', map, undefined)).toContain('CASE WHEN');
});
test('rejects non-finite or negative factors', () => {
const result = buildSourceFactorCase(
'p.slug',
{ 'good/': 1.5, 'nan/': NaN, 'neg/': -1, 'inf/': Infinity },
'medium',
);
expect(result).toBe("(CASE WHEN p.slug LIKE 'good/%' THEN 1.5 ELSE 1.0 END)");
});
test('uses the supplied slug column reference', () => {
expect(buildSourceFactorCase('slug', { 'originals/': 1.5 }, 'medium'))
.toContain('WHEN slug LIKE');
});
});
describe('buildHardExcludeClause', () => {
test('returns empty string when prefixes is empty', () => {
expect(buildHardExcludeClause('p.slug', [])).toBe('');
});
test('emits NOT (col LIKE ... OR col LIKE ...)', () => {
const result = buildHardExcludeClause('p.slug', ['test/', 'archive/']);
expect(result).toBe(`AND NOT (p.slug LIKE 'test/%' OR p.slug LIKE 'archive/%')`);
});
test('escapes %, _, and \\ as LIKE meta-characters', () => {
// CEO pass 4 + codex finding: backslash is Postgres LIKE's default escape char.
// A literal backslash in a user-supplied prefix must be escaped to \\ so
// it's treated as data, not as "escape the next char".
const result = buildHardExcludeClause('p.slug', ['weird\\path/']);
expect(result).toBe(`AND NOT (p.slug LIKE 'weird\\\\path/%')`);
});
test('treats SQL-injection-style input as literal', () => {
const result = buildHardExcludeClause('p.slug', ["'; DROP TABLE pages; --"]);
// Single quotes get doubled — the injection becomes inert text inside
// the string literal.
expect(result).toContain("''; DROP TABLE pages; --");
// Sanity: the structure of the clause is intact.
expect(result).toMatch(/^AND NOT \(p\.slug LIKE '.*%'\)$/);
});
test('skips empty-string prefixes', () => {
const result = buildHardExcludeClause('p.slug', ['test/', '', 'archive/']);
// Two LIKE clauses, one OR.
expect((result.match(/LIKE/g) || []).length).toBe(2);
});
});
describe('parseSourceBoostEnv', () => {
test('parses comma-separated prefix:factor pairs', () => {
expect(parseSourceBoostEnv('originals/:1.8,wintermute/chat/:0.3'))
.toEqual({ 'originals/': 1.8, 'wintermute/chat/': 0.3 });
});
test('returns empty object for undefined or empty', () => {
expect(parseSourceBoostEnv(undefined)).toEqual({});
expect(parseSourceBoostEnv('')).toEqual({});
});
test('skips malformed entries', () => {
expect(parseSourceBoostEnv('bogus,no-colon,originals/:abc,valid/:1.5'))
.toEqual({ 'valid/': 1.5 });
});
test('rejects negative factors', () => {
expect(parseSourceBoostEnv('foo/:-1.0,bar/:0.5')).toEqual({ 'bar/': 0.5 });
});
test('accepts factor=0 (legal but performance-inferior to hard-exclude)', () => {
expect(parseSourceBoostEnv('foo/:0')).toEqual({ 'foo/': 0 });
});
test('uses last colon to separate prefix from factor', () => {
// Edge case: someone puts a colon inside the prefix. Last colon wins.
expect(parseSourceBoostEnv('foo:bar/:1.5')).toEqual({ 'foo:bar/': 1.5 });
});
});
describe('parseHardExcludesEnv', () => {
test('parses comma-separated prefixes', () => {
expect(parseHardExcludesEnv('test/,scratch/,private/'))
.toEqual(['test/', 'scratch/', 'private/']);
});
test('returns empty array for undefined', () => {
expect(parseHardExcludesEnv(undefined)).toEqual([]);
});
test('trims whitespace and drops empty entries', () => {
expect(parseHardExcludesEnv(' test/ , , scratch/ ')).toEqual(['test/', 'scratch/']);
});
});
describe('resolveBoostMap', () => {
test('returns defaults when env is unset', () => {
expect(resolveBoostMap(undefined)).toEqual(DEFAULT_SOURCE_BOOSTS);
});
test('env override takes precedence over defaults', () => {
const merged = resolveBoostMap('originals/:99');
expect(merged['originals/']).toBe(99);
// Other defaults still present.
expect(merged['concepts/']).toBe(DEFAULT_SOURCE_BOOSTS['concepts/']);
});
test('env-only entries are added on top of defaults', () => {
const merged = resolveBoostMap('newprefix/:2.5');
expect(merged['newprefix/']).toBe(2.5);
expect(merged['originals/']).toBe(DEFAULT_SOURCE_BOOSTS['originals/']);
});
});
describe('resolveHardExcludes', () => {
test('returns defaults when nothing is overridden', () => {
const r = resolveHardExcludes(undefined, undefined, undefined);
for (const p of DEFAULT_HARD_EXCLUDES) expect(r).toContain(p);
});
test('caller exclude_slug_prefixes adds to the union', () => {
const r = resolveHardExcludes(['scratch/'], undefined, undefined);
expect(r).toContain('scratch/');
expect(r).toContain('test/'); // default still present
});
test('include_slug_prefixes opts back in', () => {
const r = resolveHardExcludes(undefined, ['test/'], undefined);
expect(r).not.toContain('test/');
// Other defaults still present.
expect(r).toContain('archive/');
});
test('env GBRAIN_SEARCH_EXCLUDE adds to the union', () => {
const r = resolveHardExcludes(undefined, undefined, 'envdir/');
expect(r).toContain('envdir/');
});
test('include subtracts from env-supplied excludes too', () => {
const r = resolveHardExcludes(undefined, ['envdir/'], 'envdir/');
expect(r).not.toContain('envdir/');
});
});