mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* feat(search): deterministic relational-query parser
Pure, ReDoS-bounded parser that detects relationship queries ("who invested
in X", "who at X works on Y", "who introduced me to X", "what connects A and
B") and maps them to typed edges. Schema-pack-extensible vocab with subset
validation against the link types ingest produces, so query-side and
ingest-side relation vocabularies can't drift. No-match / pronoun-seed /
adjacency guards keep it precision-first (a candidate only; the arm fires
only when a real seed also resolves).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(engine): relationalFanout typed-edge fan-out (both engines)
Generalizes traversePaths to a SEED ARRAY, aggregating to ranked NODES
(shortest hop, edge-richness count, via-link-types, shortest connecting
path, canonical chunk id) instead of edges. Within-source traversal (never
crosses a source boundary even across a federated scope), link_source=
'mentions' excluded by default, deleted_at filtered at seed + every neighbor
+ every node, bounded depth (<=3) + candidate cap. Adds RelationalFanoutRow
/ RelationalFanoutOpts + the relational SearchResult/SearchOpts fields to
types.
Lands in lockstep in postgres + pglite engines, pinned by a DATABASE_URL-
gated parity block in engine-parity.test.ts; a PGLite unit test exercises
the SQL (typed-edge filter, mentions exclusion, deleted_at, canonical chunk,
multi-seed connects, determinism) in default CI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(search): relational recall arm + federation key hardening
Wires edge-derived candidates into bare hybridSearch as a FOURTH RRF arm
(relational-recall.ts): parse the original query -> scope-aware,
confidence-gated seed resolution (drops fallback_slugify; never traverses
from a guess) -> relationalFanout -> batch-hydrate, reinforcing each page's
REAL canonical chunk (page-level key for chunkless entity pages) ->
--explain attribution + fail-open audit row. Text-only (no-op in image
mode); pure no-op for non-relational queries; rides every downstream stage
(cosine, post-fusion boosts, dedup, reranker, autocut, token budget).
Mode wiring: relationalRetrieval + relational_retrieval_depth knobs
(conservative off; balanced/tokenmax on; depth 2), per-call thread-through
in both bare + cached paths, KNOBS_HASH_VERSION 9->10 (rel=/reld=), config
keys, modes-dashboard descriptions, and a `relational` param on the query op.
Federation hardening (structural, engine-wide): the RRF/dedup key now
carries source_id via a shared rrfKey() (fixes a latent cross-source
collapse where same-slug pages in different sources merged), and the query
cache scopes by a canonical source-set key (cacheScopeKey) so a federated
read can't be served a single-source row.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(eval): relational benchmark + recall@k harness metrics
NamedThingBench harness gains recall@k / recall@10 (the relational headline
metric) on QuestionResult + FamilyReport, plus typed seed/linkTypes/kind on
NamedThingQuestion so the graph-relationship family is machine-checkable.
Adds the relational benchmark corpus (test/fixtures/retrieval-quality/
relational/): a small entity graph whose answers are LEXICALLY UNRECOVERABLE
— every page body is generic and never names the entity it relates to, so
only the typed edge connects query to answer. corpus.ts is the canonical
source for both the seed loader and the 38-question gold set; relational.jsonl
is generated from it (a drift test pins them equal).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(eval): relational A/B proof + arm fires on all retrieval paths
Fixes the integration bug the eval caught: the relational arm was only
injected on the main RRF path, so it silently did nothing whenever vector
was unavailable — no embedding provider configured (the default in many
deployments) OR embed failure. The arm is now built once and fused via RRF
on ALL THREE hybridSearch return paths (no-embedding-provider, embed-failed
keyword fallback, main path). Without this it would have been dead in
exactly the setups that most need it.
Adds `gbrain eval retrieval-quality --ab-relational`: runs the gold set
twice (arm off vs on) in a fixed mode and reports the graph-relationship
recall@10 lift + Hit@3 + latency add. The CI A/B test pins the headline
result — recall@10 jumps from <25% (lexically unrecoverable) to >75% with
the arm on — and a non-relational query returns identical results arm-on vs
off (the no-op / no-regression gate).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore: bump version and changelog (v0.42.34.0)
Relational retrieval feature: typed-edge recall arm + federation key
hardening. Also updates the KNOBS_HASH_VERSION 9→10 assertions across the
remaining search test files (the bump invalidates relational-off cache rows).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: document typed-edge relational retrieval (v0.42.34.0)
CLAUDE.md Search Mode: add relationalRetrieval to the knob table, the
knobs_hash v=9→10 note, and a relational-retrieval summary. RETRIEVAL.md:
add the relational recall arm to the pipeline diagram. Regenerate llms
bundles (build:llms).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(test): size relational fixtures to the actual embedding-column dim
CI shard runs with the ZeroEntropy gateway default (1280-d), but the
relational test fixtures hardcoded 1536-d embeddings, so chunk inserts were
rejected with "expected 1280 dimensions, not 1536" (CheckExpectedDim) — the
`test (6)` shard + `test-status` failures. The column width tracks the
configured gateway default and can shift with shard order, so fixtures now
probe `content_chunks.embedding`'s actual `atttypmod` after initSchema and
size embeddings to it (the pglite-engine.test.ts pattern), via a shared
`probeEmbeddingDim` helper. Verified passing at a forced 1280-d column.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
137 lines
4.9 KiB
TypeScript
137 lines
4.9 KiB
TypeScript
/**
|
|
* Relational-query parser unit tests.
|
|
*
|
|
* Covers the four archetypes, the no-match path, the precision-first
|
|
* false-positive guard, schema-pack vocab extension, subset validation, and
|
|
* the length bound (ReDoS surface).
|
|
*/
|
|
|
|
import { describe, test, expect } from 'bun:test';
|
|
import {
|
|
parseRelationalQuery,
|
|
validateVocab,
|
|
KNOWN_LINK_TYPES,
|
|
type RelationVocab,
|
|
} from '../src/core/search/relational-intent.ts';
|
|
|
|
describe('parseRelationalQuery — archetypes', () => {
|
|
test('who_rel: who invested in widget-co', () => {
|
|
const r = parseRelationalQuery('who invested in widget-co');
|
|
expect(r).not.toBeNull();
|
|
expect(r!.kind).toBe('who_rel');
|
|
expect(r!.seeds).toEqual(['widget-co']);
|
|
expect(r!.linkTypes).toEqual(['invested_in', 'led_round']);
|
|
expect(r!.direction).toBe('in');
|
|
});
|
|
|
|
test('who_rel: who founded acme (article stripped)', () => {
|
|
const r = parseRelationalQuery('who founded the acme corporation?');
|
|
expect(r!.kind).toBe('who_rel');
|
|
expect(r!.seeds).toEqual(['acme corporation']);
|
|
expect(r!.linkTypes).toEqual(['founded']);
|
|
});
|
|
|
|
test('who_at: who at acme works on payments', () => {
|
|
const r = parseRelationalQuery('who at acme works on payments');
|
|
expect(r!.kind).toBe('who_at');
|
|
expect(r!.seeds).toEqual(['acme']);
|
|
expect(r!.linkTypes).toEqual(['works_at']);
|
|
expect(r!.direction).toBe('in');
|
|
});
|
|
|
|
test('intro: who introduced me to alice-example (type-agnostic)', () => {
|
|
const r = parseRelationalQuery('who introduced me to alice-example?');
|
|
expect(r!.kind).toBe('intro');
|
|
expect(r!.seeds).toEqual(['alice-example']);
|
|
expect(r!.linkTypes).toBeNull();
|
|
expect(r!.direction).toBe('both');
|
|
});
|
|
|
|
test('connects: what connects fund-a and fund-b (two seeds)', () => {
|
|
const r = parseRelationalQuery('what connects fund-a and fund-b');
|
|
expect(r!.kind).toBe('connects');
|
|
expect(r!.seeds).toEqual(['fund-a', 'fund-b']);
|
|
expect(r!.linkTypes).toBeNull();
|
|
expect(r!.direction).toBe('both');
|
|
});
|
|
|
|
test('connects: how are X and Y related', () => {
|
|
const r = parseRelationalQuery('how are widget-co and acme related');
|
|
expect(r!.kind).toBe('connects');
|
|
expect(r!.seeds).toEqual(['widget-co', 'acme']);
|
|
});
|
|
|
|
test('outgoing: what did alice invest in', () => {
|
|
const r = parseRelationalQuery('what did alice invest in');
|
|
expect(r!.kind).toBe('who_rel');
|
|
expect(r!.seeds).toEqual(['alice']);
|
|
expect(r!.direction).toBe('out');
|
|
});
|
|
});
|
|
|
|
describe('parseRelationalQuery — precision-first / no-match', () => {
|
|
test('non-relational content query → null', () => {
|
|
expect(parseRelationalQuery('what is the capital structure of a seed round')).toBeNull();
|
|
expect(parseRelationalQuery('notes from the offsite')).toBeNull();
|
|
expect(parseRelationalQuery('summarize the q3 board deck')).toBeNull();
|
|
});
|
|
|
|
test('false-positive: "who invested TIME in learning Rust" does NOT match', () => {
|
|
// "invested time in" is not "invested in" — adjacency guard.
|
|
expect(parseRelationalQuery('who invested time in learning Rust')).toBeNull();
|
|
});
|
|
|
|
test('pronoun / stopword seed is rejected', () => {
|
|
expect(parseRelationalQuery('who invested in it')).toBeNull();
|
|
expect(parseRelationalQuery('who founded them?')).toBeNull();
|
|
});
|
|
|
|
test('empty / overlong input → null', () => {
|
|
expect(parseRelationalQuery('')).toBeNull();
|
|
expect(parseRelationalQuery('who invested in ' + 'x'.repeat(600))).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('schema-pack vocab extension (D2=B)', () => {
|
|
const vocab: RelationVocab = {
|
|
extraVerbs: [{ verb: 'related to|associated with', linkTypes: ['related_to'], direction: 'both' }],
|
|
};
|
|
|
|
test('extra verb extends detection', () => {
|
|
expect(parseRelationalQuery('who related to widget-co', vocab)!.linkTypes).toEqual(['related_to']);
|
|
// same query without the pack does NOT match the extra verb
|
|
expect(parseRelationalQuery('who related to widget-co')).toBeNull();
|
|
});
|
|
|
|
test('validateVocab passes for known types', () => {
|
|
expect(() => validateVocab(vocab)).not.toThrow();
|
|
});
|
|
|
|
test('validateVocab throws on unknown link_type', () => {
|
|
expect(() =>
|
|
validateVocab({ extraVerbs: [{ verb: 'pwns', linkTypes: ['not_a_real_edge'], direction: 'in' }] }),
|
|
).toThrow(/unknown link_type/);
|
|
});
|
|
});
|
|
|
|
describe('default bank emits only known link types (no drift)', () => {
|
|
test('every parsed linkType is a subset of KNOWN_LINK_TYPES', () => {
|
|
const queries = [
|
|
'who invested in widget-co',
|
|
'who founded acme',
|
|
'who advises bob-example',
|
|
'who works at acme',
|
|
'who at acme leads payments',
|
|
'what did alice invest in',
|
|
'where does alice work',
|
|
];
|
|
for (const q of queries) {
|
|
const r = parseRelationalQuery(q);
|
|
if (!r || r.linkTypes === null) continue;
|
|
for (const lt of r.linkTypes) {
|
|
expect(KNOWN_LINK_TYPES.has(lt)).toBe(true);
|
|
}
|
|
}
|
|
});
|
|
});
|