mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
* 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>
228 lines
8.5 KiB
TypeScript
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);
|
|
});
|
|
});
|