mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
Bare names resolve only when prefix expansion finds exactly one canonical candidate; ambiguous collisions and low-specificity multi-token fuzzy matches fall through to the guarded holding path instead of confident wrong attribution. Fixes #2723.
327 lines
13 KiB
TypeScript
327 lines
13 KiB
TypeScript
import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
|
|
import {
|
|
resolveEntitySlug,
|
|
resolveEntitySlugWithSource,
|
|
slugify,
|
|
type ResolutionSource,
|
|
} from '../src/core/entities/resolve.ts';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import type { BrainEngine } from '../src/core/engine.ts';
|
|
|
|
/**
|
|
* Entity resolution prefix expansion tests.
|
|
*
|
|
* Validates that bare first names resolve to existing pages via prefix
|
|
* expansion, preventing phantom stub creation.
|
|
*
|
|
* Fixture names use the `alice-example` / `bob-example` / `charlie-example`
|
|
* / `dave-example` placeholder pattern per CLAUDE.md privacy rule.
|
|
* `stripe` and `stripe-atlas` are intentional — household-brand exception
|
|
* exercises the two-word company prefix case.
|
|
*/
|
|
|
|
let engine: PGLiteEngine;
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({ database_url: '' });
|
|
await engine.initSchema();
|
|
|
|
// Seed test pages. Naming pattern:
|
|
// - alice-example: single-match case (only people/alice-*)
|
|
// - bob-example vs bob-rosenstein: ambiguous bare-name collision
|
|
// - charlie-example vs charlie-bankcroft: ambiguous bare-name collision
|
|
// - dave-example: single-match case
|
|
const pages = [
|
|
{ slug: 'people/alice-example', title: 'Alice Example', type: 'person' },
|
|
{ slug: 'people/bob-example', title: 'Bob Example', type: 'person' },
|
|
{ slug: 'people/bob-rosenstein', title: 'Bob Rosenstein', type: 'person' },
|
|
{ slug: 'people/charlie-example', title: 'Charlie Example', type: 'person' },
|
|
{ slug: 'people/charlie-bankcroft', title: 'Charlie Bankcroft', type: 'person' },
|
|
{ slug: 'people/dave-example', title: 'Dave Example', type: 'person' },
|
|
{ slug: 'companies/stripe', title: 'Stripe', type: 'company' },
|
|
{ slug: 'companies/stripe-atlas', title: 'Stripe Atlas', type: 'company' },
|
|
{ slug: 'companies/benton-capital', title: 'Benton Capital', type: 'company' },
|
|
];
|
|
|
|
for (const p of pages) {
|
|
await engine.putPage(p.slug, {
|
|
type: p.type as any,
|
|
title: p.title,
|
|
compiled_truth: `# ${p.title}`,
|
|
frontmatter: { type: p.type, title: p.title, slug: p.slug },
|
|
}, { sourceId: 'default' });
|
|
}
|
|
|
|
// Give alice-example 10 chunks (single match, ensures it's the resolved target)
|
|
const alicePage = await engine.executeRaw<{ id: string }>(
|
|
`SELECT id FROM pages WHERE slug = 'people/alice-example' AND source_id = 'default'`,
|
|
[],
|
|
);
|
|
if (alicePage.length > 0) {
|
|
for (let i = 0; i < 10; i++) {
|
|
await engine.executeRaw(
|
|
`INSERT INTO content_chunks (page_id, chunk_index, chunk_text)
|
|
VALUES ($1, $2, $3)`,
|
|
[alicePage[0].id, i, `Chunk ${i} about Alice Example`],
|
|
);
|
|
}
|
|
}
|
|
|
|
// Give charlie-example more connections than charlie-bankcroft (20 vs 0)
|
|
const charliePage = await engine.executeRaw<{ id: string }>(
|
|
`SELECT id FROM pages WHERE slug = 'people/charlie-example' AND source_id = 'default'`,
|
|
[],
|
|
);
|
|
if (charliePage.length > 0) {
|
|
for (let i = 0; i < 20; i++) {
|
|
await engine.executeRaw(
|
|
`INSERT INTO content_chunks (page_id, chunk_index, chunk_text)
|
|
VALUES ($1, $2, $3)`,
|
|
[charliePage[0].id, i, `Chunk ${i} about Charlie Example`],
|
|
);
|
|
}
|
|
}
|
|
|
|
// Give bob-example more connections than bob-rosenstein (15 vs 0)
|
|
const bobPage = await engine.executeRaw<{ id: string }>(
|
|
`SELECT id FROM pages WHERE slug = 'people/bob-example' AND source_id = 'default'`,
|
|
[],
|
|
);
|
|
if (bobPage.length > 0) {
|
|
for (let i = 0; i < 15; i++) {
|
|
await engine.executeRaw(
|
|
`INSERT INTO content_chunks (page_id, chunk_index, chunk_text)
|
|
VALUES ($1, $2, $3)`,
|
|
[bobPage[0].id, i, `Chunk ${i} about Bob Example`],
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
});
|
|
|
|
describe('resolveEntitySlug — prefix expansion', () => {
|
|
it('resolves "Alice" to people/alice-example', async () => {
|
|
const result = await resolveEntitySlug(engine as unknown as BrainEngine, 'default', 'Alice');
|
|
expect(result).toBe('people/alice-example');
|
|
});
|
|
|
|
it('resolves "alice" (lowercase) to people/alice-example', async () => {
|
|
const result = await resolveEntitySlug(engine as unknown as BrainEngine, 'default', 'alice');
|
|
expect(result).toBe('people/alice-example');
|
|
});
|
|
|
|
it('refuses to choose between people sharing the same bare first name', async () => {
|
|
const result = await resolveEntitySlug(engine as unknown as BrainEngine, 'default', 'Bob');
|
|
expect(result).toBe('bob');
|
|
});
|
|
|
|
it('does not use connection count to turn bare-name ambiguity into confidence', async () => {
|
|
const result = await resolveEntitySlug(engine as unknown as BrainEngine, 'default', 'Charlie');
|
|
expect(result).toBe('charlie');
|
|
});
|
|
|
|
it('resolves "Dave" to people/dave-example (single match)', async () => {
|
|
const result = await resolveEntitySlug(engine as unknown as BrainEngine, 'default', 'Dave');
|
|
expect(result).toBe('people/dave-example');
|
|
});
|
|
|
|
it('falls through to slugify for unknown names', async () => {
|
|
const result = await resolveEntitySlug(engine as unknown as BrainEngine, 'default', 'Zyxwvut');
|
|
expect(result).toBe('zyxwvut');
|
|
});
|
|
|
|
it('exact match still works for fully-qualified slugs', async () => {
|
|
const result = await resolveEntitySlug(engine as unknown as BrainEngine, 'default', 'people/alice-example');
|
|
expect(result).toBe('people/alice-example');
|
|
});
|
|
|
|
it('multi-word input does NOT trigger prefix expansion', async () => {
|
|
// "Alice Example" should go through fuzzy match, not prefix expansion
|
|
const result = await resolveEntitySlug(engine as unknown as BrainEngine, 'default', 'Alice Example');
|
|
// Should resolve via fuzzy match to the same page
|
|
expect(result).toContain('alice-example');
|
|
});
|
|
|
|
it('preserves a high-specificity multi-token typo match', async () => {
|
|
const result = await resolveEntitySlug(engine as unknown as BrainEngine, 'default', 'Alice Exampl');
|
|
expect(result).toBe('people/alice-example');
|
|
});
|
|
|
|
it('hyphenated input does NOT trigger prefix expansion', async () => {
|
|
const result = await resolveEntitySlug(engine as unknown as BrainEngine, 'default', 'alice-example');
|
|
expect(result).toBe('people/alice-example');
|
|
});
|
|
|
|
it('preserves an explicit full company-name match', async () => {
|
|
const result = await resolveEntitySlug(engine as unknown as BrainEngine, 'default', 'Benton Capital');
|
|
expect(result).toBe('companies/benton-capital');
|
|
});
|
|
|
|
it('refuses a company match supported mainly by a shared generic token', async () => {
|
|
const result = await resolveEntitySlug(engine as unknown as BrainEngine, 'default', 'Beacon Capital');
|
|
expect(result).toBe('beacon-capital');
|
|
});
|
|
|
|
it('returns null for empty input', async () => {
|
|
const result = await resolveEntitySlug(engine as unknown as BrainEngine, 'default', '');
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('slugify', () => {
|
|
it('lowercases and hyphenates', () => {
|
|
expect(slugify('Alice Example')).toBe('alice-example');
|
|
});
|
|
|
|
it('handles single word', () => {
|
|
expect(slugify('Alice')).toBe('alice');
|
|
});
|
|
|
|
it('strips accents', () => {
|
|
expect(slugify('José García')).toBe('jose-garcia');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// v0.40.2.0 — resolveEntitySlugWithSource
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
//
|
|
// Same resolution chain as resolveEntitySlug, but returns the source
|
|
// tag (`exact_page` | `fuzzy_match` | `fallback_slugify`) so trajectory
|
|
// routing in `gbrain think` (Commit 2) can gate on
|
|
// `resolution_source !== 'fallback_slugify'` and avoid querying invented
|
|
// slugs in production. The longmemeval harness accepts fallback_slugify
|
|
// because its extractor uses the same slugify fallback (they cohere).
|
|
//
|
|
// These tests pin the source-tag contract per branch.
|
|
|
|
describe('resolveEntitySlugWithSource — exact_page branch', () => {
|
|
it('returns exact_page when raw is a full slug that exists', async () => {
|
|
const result = await resolveEntitySlugWithSource(
|
|
engine as unknown as BrainEngine,
|
|
'default',
|
|
'people/alice-example',
|
|
);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.slug).toBe('people/alice-example');
|
|
expect(result!.source).toBe<ResolutionSource>('exact_page');
|
|
});
|
|
|
|
it('returns exact_page when raw is a slug-shape match (lowercase, slash)', async () => {
|
|
// Pre-existing companies/stripe is seeded; raw is exact.
|
|
const result = await resolveEntitySlugWithSource(
|
|
engine as unknown as BrainEngine,
|
|
'default',
|
|
'companies/stripe',
|
|
);
|
|
expect(result!.slug).toBe('companies/stripe');
|
|
expect(result!.source).toBe<ResolutionSource>('exact_page');
|
|
});
|
|
});
|
|
|
|
describe('resolveEntitySlugWithSource — fuzzy_match branch', () => {
|
|
it('returns fuzzy_match for a Title-cased display name', async () => {
|
|
const result = await resolveEntitySlugWithSource(
|
|
engine as unknown as BrainEngine,
|
|
'default',
|
|
'Alice Example',
|
|
);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.slug).toBe('people/alice-example');
|
|
expect(result!.source).toBe<ResolutionSource>('fuzzy_match');
|
|
});
|
|
|
|
it('returns fuzzy_match for prefix-expansion (bare first name "Alice")', async () => {
|
|
// Bare name "Alice" doesn't exact-match any slug, fuzzy fails the
|
|
// 0.4 threshold on short trigrams, so prefix expansion fires and
|
|
// resolves to people/alice-example. We tag this branch as
|
|
// fuzzy_match (not fallback_slugify) so trajectory routing knows
|
|
// it's a real-page resolution.
|
|
const result = await resolveEntitySlugWithSource(
|
|
engine as unknown as BrainEngine,
|
|
'default',
|
|
'Alice',
|
|
);
|
|
expect(result!.slug).toBe('people/alice-example');
|
|
expect(result!.source).toBe<ResolutionSource>('fuzzy_match');
|
|
});
|
|
});
|
|
|
|
describe('resolveEntitySlugWithSource — fallback_slugify branch', () => {
|
|
it('returns fallback_slugify when no page matches', async () => {
|
|
// "Zelda" isn't seeded; no exact, no fuzzy (no people/zelda-*),
|
|
// prefix expansion finds nothing, falls through to slugify.
|
|
const result = await resolveEntitySlugWithSource(
|
|
engine as unknown as BrainEngine,
|
|
'default',
|
|
'Zelda',
|
|
);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.slug).toBe('zelda');
|
|
expect(result!.source).toBe<ResolutionSource>('fallback_slugify');
|
|
});
|
|
|
|
it('returns fallback_slugify for multi-word non-match phrase', async () => {
|
|
// "coffee maker" — common-noun phrase the trajectory router may
|
|
// pull from question text. No page, no fuzzy hit (multi-word but
|
|
// generic), no prefix expansion (multi-token rejects bare-name
|
|
// heuristic), so slugify fires.
|
|
const result = await resolveEntitySlugWithSource(
|
|
engine as unknown as BrainEngine,
|
|
'default',
|
|
'coffee maker',
|
|
);
|
|
expect(result!.slug).toBe('coffee-maker');
|
|
expect(result!.source).toBe<ResolutionSource>('fallback_slugify');
|
|
});
|
|
|
|
it('returns fallback_slugify for accented input (slugify path strips)', async () => {
|
|
const result = await resolveEntitySlugWithSource(
|
|
engine as unknown as BrainEngine,
|
|
'default',
|
|
'José García',
|
|
);
|
|
expect(result!.slug).toBe('jose-garcia');
|
|
expect(result!.source).toBe<ResolutionSource>('fallback_slugify');
|
|
});
|
|
});
|
|
|
|
describe('resolveEntitySlugWithSource — null tail', () => {
|
|
it('returns null for empty input', async () => {
|
|
const result = await resolveEntitySlugWithSource(
|
|
engine as unknown as BrainEngine,
|
|
'default',
|
|
'',
|
|
);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('returns null for whitespace-only input', async () => {
|
|
const result = await resolveEntitySlugWithSource(
|
|
engine as unknown as BrainEngine,
|
|
'default',
|
|
' ',
|
|
);
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('resolveEntitySlugWithSource — back-compat with resolveEntitySlug', () => {
|
|
it('exact_page branch matches resolveEntitySlug output (same slug, plus source tag)', async () => {
|
|
const a = await resolveEntitySlug(engine as unknown as BrainEngine, 'default', 'people/alice-example');
|
|
const b = await resolveEntitySlugWithSource(engine as unknown as BrainEngine, 'default', 'people/alice-example');
|
|
expect(b!.slug).toBe(a!);
|
|
});
|
|
|
|
it('fallback_slugify branch matches resolveEntitySlug output (same slug, plus source tag)', async () => {
|
|
const a = await resolveEntitySlug(engine as unknown as BrainEngine, 'default', 'Zelda');
|
|
const b = await resolveEntitySlugWithSource(engine as unknown as BrainEngine, 'default', 'Zelda');
|
|
expect(b!.slug).toBe(a!);
|
|
expect(b!.source).toBe<ResolutionSource>('fallback_slugify');
|
|
});
|
|
});
|