mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 08:53:22 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e74fe0605 | ||
|
|
cb2b44ba11 |
+3
-1
@@ -888,7 +888,9 @@ export function formatResult(opName: string, result: unknown): string {
|
||||
return formatResultsExplain(results);
|
||||
}
|
||||
return results.map(r =>
|
||||
`[${r.score?.toFixed(4) || '?'}] ${r.slug} -- ${r.chunk_text?.slice(0, 100) || ''}${r.stale ? ' (stale)' : ''}`,
|
||||
// #468: NaN.toFixed(4) is the truthy string 'NaN', so `?.toFixed() || '?'`
|
||||
// printed '[NaN]'. Gate on Number.isFinite instead.
|
||||
`[${Number.isFinite(r.score) ? r.score.toFixed(4) : '?'}] ${r.slug} -- ${r.chunk_text?.slice(0, 100) || ''}${r.stale ? ' (stale)' : ''}`,
|
||||
).join('\n') + '\n';
|
||||
}
|
||||
case 'get_tags': {
|
||||
|
||||
@@ -528,6 +528,56 @@ export async function childTableOrphansCheck(engine: BrainEngine): Promise<Check
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* #550 — pages(source_id, slug) unique-index presence.
|
||||
*
|
||||
* `putPage` in BOTH engines upserts via `ON CONFLICT (source_id, slug)`, which
|
||||
* needs a non-partial unique index whose column set is exactly
|
||||
* {source_id, slug} as its arbiter. Migration v23 adds `pages_source_slug_key`
|
||||
* by NAME, so a brain whose constraint was dropped/renamed by an external
|
||||
* migration is stamped past v23 with silently broken writes ("no unique or
|
||||
* exclusion constraint matching the ON CONFLICT specification") that the
|
||||
* version counter can't see. Match by COLUMNS, not name — any conforming
|
||||
* unique index satisfies the arbiter (mirror of the #2038 timeline_dedup
|
||||
* shape check).
|
||||
*/
|
||||
export async function checkPagesSlugUniqueIndex(engine: BrainEngine): Promise<Check> {
|
||||
const name = 'pages_slug_unique_index';
|
||||
try {
|
||||
const rows = await engine.executeRaw<{ indexdef: string }>(
|
||||
`SELECT indexdef FROM pg_indexes WHERE tablename = 'pages'`,
|
||||
);
|
||||
const ok = rows.some(r => {
|
||||
const def = r.indexdef;
|
||||
// Partial indexes can't arbitrate a bare ON CONFLICT (source_id, slug).
|
||||
if (!/^CREATE UNIQUE INDEX/i.test(def) || /\bWHERE\b/i.test(def)) return false;
|
||||
const open = def.lastIndexOf('(');
|
||||
const close = def.lastIndexOf(')');
|
||||
if (open < 0 || close < open) return false;
|
||||
const cols = def
|
||||
.slice(open + 1, close)
|
||||
.split(',')
|
||||
.map(c => c.trim().split(/\s+/)[0]) // drop DESC / opclass suffixes
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
return cols.length === 2 && cols[0] === 'slug' && cols[1] === 'source_id';
|
||||
});
|
||||
if (ok) {
|
||||
return { name, status: 'ok', message: 'pages has a unique index on (source_id, slug)' };
|
||||
}
|
||||
return {
|
||||
name,
|
||||
status: 'fail',
|
||||
message:
|
||||
'No unique index on pages(source_id, slug) — every put_page write fails with ' +
|
||||
'"no unique or exclusion constraint matching the ON CONFLICT specification" (#550). ' +
|
||||
'Run `gbrain apply-migrations --force-schema` to restore it.',
|
||||
};
|
||||
} catch {
|
||||
return { name, status: 'warn', message: 'Could not check pages(source_id, slug) unique index' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function doctorReportRemote(engine: BrainEngine): Promise<DoctorReport> {
|
||||
const checks: Check[] = [];
|
||||
|
||||
@@ -602,6 +652,9 @@ export async function doctorReportRemote(engine: BrainEngine): Promise<DoctorRep
|
||||
checks.push({ name: 'timeline_dedup_index', status: 'warn', message: 'Could not check idx_timeline_dedup shape' });
|
||||
}
|
||||
|
||||
// 2c. #550: pages(source_id, slug) unique index — putPage's ON CONFLICT arbiter.
|
||||
checks.push(await checkPagesSlugUniqueIndex(engine));
|
||||
|
||||
// v0.42.x — Life Chronicle (#2390): orphaned event projections. Reads already
|
||||
// hide projections whose event page is soft-deleted (read-time correctness);
|
||||
// this always-run probe surfaces the cleanup backlog. Keyed off the real
|
||||
@@ -5249,6 +5302,10 @@ export async function buildChecks(
|
||||
progress.heartbeat('pgvector');
|
||||
checks.push(await pgvectorCheck(engine));
|
||||
|
||||
// 4a. #550: pages(source_id, slug) unique index — putPage's ON CONFLICT arbiter.
|
||||
progress.heartbeat('pages_slug_unique_index');
|
||||
checks.push(await checkPagesSlugUniqueIndex(engine));
|
||||
|
||||
// 4b. PgBouncer / prepared-statement compatibility.
|
||||
// URL-only inspection — no DB roundtrip — so this is cheap and works
|
||||
// regardless of whether the caller is the module singleton or a
|
||||
|
||||
@@ -170,6 +170,7 @@ export const META_CHECK_NAMES: ReadonlySet<string> = new Set([
|
||||
'eval_capture',
|
||||
'minions_migration',
|
||||
'multi_source_drift',
|
||||
'pages_slug_unique_index',
|
||||
'schema_pack_active',
|
||||
'schema_pack_consistency',
|
||||
'schema_pack_source_drift',
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* #468 — `[NaN]` score prefix in `gbrain query` output.
|
||||
*
|
||||
* NaN.toFixed(4) returns the truthy string 'NaN', so the old
|
||||
* `r.score?.toFixed(4) || '?'` fallback never fired and the CLI printed
|
||||
* '[NaN] slug -- ...'. The formatter must gate on Number.isFinite.
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { formatResult } from '../src/cli.ts';
|
||||
|
||||
describe('formatResult — query score prefix (#468)', () => {
|
||||
test('finite score renders 4 decimal places', () => {
|
||||
const out = formatResult('query', [
|
||||
{ score: 0.4922, slug: 'people/zhangsan', chunk_text: 'Zhang San' },
|
||||
]);
|
||||
expect(out).toContain('[0.4922] people/zhangsan');
|
||||
});
|
||||
|
||||
test('NaN score renders ? — never the string NaN', () => {
|
||||
const out = formatResult('query', [
|
||||
{ score: NaN, slug: 'people/zhangsan', chunk_text: 'Zhang San' },
|
||||
]);
|
||||
expect(out).toContain('[?] people/zhangsan');
|
||||
expect(out).not.toContain('NaN');
|
||||
});
|
||||
|
||||
test('missing and Infinity scores also render ?', () => {
|
||||
const out = formatResult('search', [
|
||||
{ slug: 'a', chunk_text: '' },
|
||||
{ score: Infinity, slug: 'b', chunk_text: '' },
|
||||
]);
|
||||
expect(out).toContain('[?] a');
|
||||
expect(out).toContain('[?] b');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* #550 — doctor check for the pages(source_id, slug) unique index.
|
||||
*
|
||||
* putPage upserts via ON CONFLICT (source_id, slug); a brain whose
|
||||
* pages_source_slug_key constraint was dropped/renamed by an external
|
||||
* migration has silently broken writes the version counter can't see.
|
||||
* The check must match by COLUMNS, not by constraint name.
|
||||
*/
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { checkPagesSlugUniqueIndex } from '../src/commands/doctor.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
});
|
||||
|
||||
describe('checkPagesSlugUniqueIndex (#550)', () => {
|
||||
test('fresh schema → ok', async () => {
|
||||
const check = await checkPagesSlugUniqueIndex(engine);
|
||||
expect(check.status).toBe('ok');
|
||||
});
|
||||
|
||||
test('constraint dropped → fail with apply-migrations hint, and putPage really breaks', async () => {
|
||||
await engine.executeRaw('ALTER TABLE pages DROP CONSTRAINT pages_source_slug_key');
|
||||
const check = await checkPagesSlugUniqueIndex(engine);
|
||||
expect(check.status).toBe('fail');
|
||||
expect(check.message).toContain('apply-migrations');
|
||||
// The condition doctor now detects is a real write outage:
|
||||
await expect(
|
||||
engine.putPage('people/x', { type: 'person', title: 'X', compiled_truth: 'x' }),
|
||||
).rejects.toThrow(/unique or exclusion constraint/);
|
||||
});
|
||||
|
||||
test('conforming unique index under a DIFFERENT name/order → ok (columns, not name)', async () => {
|
||||
await engine.executeRaw(
|
||||
'CREATE UNIQUE INDEX pages_custom_uniq ON pages (slug, source_id)',
|
||||
);
|
||||
const check = await checkPagesSlugUniqueIndex(engine);
|
||||
expect(check.status).toBe('ok');
|
||||
await engine.executeRaw('DROP INDEX pages_custom_uniq');
|
||||
});
|
||||
|
||||
test('partial unique index does NOT satisfy the arbiter → still fail', async () => {
|
||||
await engine.executeRaw(
|
||||
"CREATE UNIQUE INDEX pages_partial_uniq ON pages (source_id, slug) WHERE type = 'person'",
|
||||
);
|
||||
const check = await checkPagesSlugUniqueIndex(engine);
|
||||
expect(check.status).toBe('fail');
|
||||
await engine.executeRaw('DROP INDEX pages_partial_uniq');
|
||||
// Restore the canonical constraint so later suites sharing this DB stay valid.
|
||||
await engine.executeRaw(
|
||||
'ALTER TABLE pages ADD CONSTRAINT pages_source_slug_key UNIQUE (source_id, slug)',
|
||||
);
|
||||
const check2 = await checkPagesSlugUniqueIndex(engine);
|
||||
expect(check2.status).toBe('ok');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* #895 — ranking inversion repro receipt.
|
||||
*
|
||||
* Reported on v0.31.3 (PGLite): `gbrain query "Who is Zhang San"` ranked a
|
||||
* heavily-backlinked concept page (concepts/memory-augmented-retrieval,
|
||||
* score 1.07) ABOVE the exact-name person page (people/zhangsan, 0.49).
|
||||
* The pipeline has since gained title-match boost, backlink floor gating,
|
||||
* and NaN guards. This test pins the fixture so the inversion can't return:
|
||||
* the exact-name page must outrank the backlink-magnet concept page.
|
||||
*
|
||||
* Keyword-only path (no embedding provider in test) — the backlink boost
|
||||
* applies post-fusion regardless of which recall arm produced the result.
|
||||
*/
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { hybridSearch } from '../src/core/search/hybrid.ts';
|
||||
import { withEnv } from './helpers/with-env.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
|
||||
const FIXTURE: Array<{ slug: string; type: string; title: string; body: string }> = [
|
||||
{
|
||||
slug: 'people/zhangsan',
|
||||
type: 'person',
|
||||
title: 'Zhang San',
|
||||
body: 'Zhang San, famously known as the forgetful founder, builds memory tools.',
|
||||
},
|
||||
{
|
||||
slug: 'people/lisi',
|
||||
type: 'person',
|
||||
title: 'Li Si',
|
||||
body: 'Li Si, nicknamed "The Spender", works with Zhang San on retrieval experiments.',
|
||||
},
|
||||
{
|
||||
slug: 'companies/goldfish-memory-tech',
|
||||
type: 'company',
|
||||
title: 'Goldfish Memory Tech',
|
||||
body: 'Goldfish Memory Tech was founded by Zhang San to commercialize memory augmentation.',
|
||||
},
|
||||
{
|
||||
slug: 'concepts/memory-augmented-retrieval',
|
||||
type: 'concept',
|
||||
title: 'Memory Augmented Retrieval',
|
||||
body: 'Memory Spa Method. Zhang San popularized memory augmented retrieval as a discipline.',
|
||||
},
|
||||
{
|
||||
slug: 'meetings/may-2026-meetup',
|
||||
type: 'meeting',
|
||||
title: 'May 2026 Meetup',
|
||||
body: 'The symposium was held in May. Zhang San presented the Memory Spa Method.',
|
||||
},
|
||||
];
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
for (const p of FIXTURE) {
|
||||
await engine.putPage(p.slug, { type: p.type, title: p.title, compiled_truth: p.body });
|
||||
// putPage never chunks; the keyword arm joins content_chunks.
|
||||
await engine.upsertChunks(p.slug, [
|
||||
{ chunk_index: 0, chunk_text: p.body, chunk_source: 'compiled_truth' },
|
||||
]);
|
||||
}
|
||||
// Make the concept page a backlink magnet — the boost source of the
|
||||
// reported inversion. Every other page links to it.
|
||||
await engine.addLinksBatch(
|
||||
FIXTURE.filter(p => p.slug !== 'concepts/memory-augmented-retrieval').map(p => ({
|
||||
from_slug: p.slug,
|
||||
to_slug: 'concepts/memory-augmented-retrieval',
|
||||
link_type: 'mentions',
|
||||
link_source: 'markdown',
|
||||
context: '',
|
||||
})),
|
||||
);
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
});
|
||||
|
||||
describe('exact-name page vs backlink-magnet concept page (#895)', () => {
|
||||
test('"Who is Zhang San" ranks people/zhangsan first, with finite scores', async () => {
|
||||
// Keyword-only path: no embedding provider during the search call.
|
||||
const results = await withEnv({ OPENAI_API_KEY: undefined }, () =>
|
||||
hybridSearch(engine, 'Who is Zhang San', { limit: 5 }),
|
||||
);
|
||||
expect(results.length).toBeGreaterThan(1);
|
||||
expect(results[0].slug).toBe('people/zhangsan');
|
||||
for (const r of results) {
|
||||
expect(Number.isFinite(r.score)).toBe(true);
|
||||
}
|
||||
// The concept page must not outrank the exact-name page even with
|
||||
// backlinks from every other page in the corpus. It IS in the result
|
||||
// set (it mentions Zhang San) and it IS backlink-boosted — that's the
|
||||
// contested comparison from the report.
|
||||
const concept = results.find(r => r.slug === 'concepts/memory-augmented-retrieval');
|
||||
expect(concept).toBeDefined();
|
||||
expect(concept!.backlink_boost ?? 1).toBeGreaterThan(1);
|
||||
expect(results[0].score).toBeGreaterThanOrEqual(concept!.score);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user