feat: Add comprehensive evaluation framework for CodeGraph API testing

Introduces automated testing infrastructure to measure CodeGraph performance across searchNodes and findRelevantContext APIs. Includes recall/MRR scoring metrics, predefined test cases for symbol lookup and context exploration, and JSON report generation. Enhances context building with acronym extraction, definition prefix matching, and improved FTS filtering to exclude imports by default.
This commit is contained in:
Colby McHenry
2026-04-06 13:24:27 -05:00
parent d4258b1651
commit 13d3ff3613
5 changed files with 403 additions and 1 deletions
+68 -1
View File
@@ -68,6 +68,14 @@ function extractSymbolsFromQuery(query: string): string[] {
}
}
// Extract ALL_CAPS acronyms (2+ chars, e.g., REST, HTTP, LRU, API)
const acronymPattern = /\b([A-Z]{2,})\b/g;
while ((match = acronymPattern.exec(query)) !== null) {
if (match[1]) {
symbols.add(match[1]);
}
}
// Extract dot.notation and split into parts (e.g., "app.isPackaged" -> ["app", "isPackaged"])
const dotPattern = /\b([a-zA-Z][a-zA-Z0-9]*(?:\.[a-zA-Z][a-zA-Z0-9]*)+)\b/g;
while ((match = dotPattern.exec(query)) !== null) {
@@ -107,6 +115,19 @@ function extractSymbolsFromQuery(query: string): string[] {
'more', 'most', 'very', 'being', 'having', 'doing',
'system', 'need', 'needs', 'want', 'wants', 'like', 'look',
'change', 'changes', 'changed', 'changing',
// Common English nouns/verbs that match thousands of unrelated code symbols
'layer', 'handle', 'handles', 'handling', 'incoming', 'outgoing',
'data', 'flow', 'flows', 'level', 'levels', 'request', 'requests',
'response', 'responses', 'implement', 'implements', 'implementation',
'interface', 'interfaces', 'class', 'classes', 'method', 'methods',
'trigger', 'triggers', 'affected', 'affect', 'affects',
'else', 'code', 'failing', 'failed', 'silently', 'decide', 'decides',
'connect', 'connection', 'connections',
'return', 'returns', 'returned', 'take', 'takes', 'taken',
'send', 'sends', 'receive', 'receives', 'process', 'processes',
'check', 'checks', 'checked', 'create', 'creates', 'created',
'read', 'reads', 'write', 'writes', 'written',
'start', 'starts', 'stop', 'stops', 'run', 'runs', 'running',
]);
return Array.from(symbols).filter(s => !commonWords.has(s.toLowerCase()));
@@ -327,6 +348,43 @@ export class ContextBuilder {
}
}
// Step 2b: Search for extracted symbols as definition (class/interface) prefixes.
// When the user writes "REST", "bulk", or "allocation", they usually mean classes
// like RestController, BulkRequest, AllocationService — not nodes named exactly that.
if (symbolsFromQuery.length > 0) {
const definitionKinds: NodeKind[] = ['class', 'interface', 'struct', 'trait',
'protocol', 'enum', 'type_alias'];
for (const sym of symbolsFromQuery) {
// Title-case the symbol: "REST" → "Rest", "bulk" → "Bulk", "allocation" → "Allocation"
const titleCased = sym.charAt(0).toUpperCase() + sym.slice(1).toLowerCase();
if (titleCased === sym) continue; // already title-case (e.g., "Engine") — handled by exact match
// Fetch more results since popular prefixes have many matches
const prefixResults = this.queries.searchNodes(titleCased, {
limit: 30,
kinds: definitionKinds,
});
const matched: SearchResult[] = [];
for (const r of prefixResults) {
if (r.node.name.toLowerCase().startsWith(titleCased.toLowerCase())) {
// Favor shorter names: "AllocationService" (18 chars) over
// "AllocationBalancingRoundMetrics" (31 chars). Core classes tend
// to have concise names; test/helper classes are verbose.
const brevityBonus = Math.max(0, 10 - (r.node.name.length - titleCased.length) / 3);
matched.push({ ...r, score: r.score + 15 + brevityBonus });
}
}
matched.sort((a, b) => b.score - a.score);
for (const r of matched.slice(0, Math.ceil(opts.searchLimit))) {
const existing = exactMatches.find(e => e.node.id === r.node.id);
if (!existing) {
exactMatches.push(r);
}
}
}
exactMatches.sort((a, b) => b.score - a.score);
exactMatches = exactMatches.slice(0, Math.ceil(opts.searchLimit * 3));
}
// Step 3: Try semantic search if vector manager is available
let semanticResults: SearchResult[] = [];
if (this.vectorManager && this.vectorManager.isInitialized()) {
@@ -352,10 +410,19 @@ export class ContextBuilder {
// Search each term individually to get broader coverage,
// then boost results that match multiple terms
const termResultsMap = new Map<string, { result: SearchResult; termHits: number }>();
// When no explicit kind filter is set, exclude imports — they flood FTS
// results with qualified name matches (e.g., "REST" matches 445K import paths)
// but are almost never what exploration queries want.
const searchKinds = opts.nodeKinds && opts.nodeKinds.length > 0
? opts.nodeKinds
: ['file', 'module', 'class', 'struct', 'interface', 'trait', 'protocol',
'function', 'method', 'property', 'field', 'variable', 'constant',
'enum', 'enum_member', 'type_alias', 'namespace', 'export',
'route', 'component'] as NodeKind[];
for (const term of searchTerms) {
const termResults = this.queries.searchNodes(term, {
limit: opts.searchLimit * 2,
kinds: opts.nodeKinds && opts.nodeKinds.length > 0 ? opts.nodeKinds : undefined,
kinds: searchKinds,
});
for (const r of termResults) {
const existing = termResultsMap.get(r.node.id);