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
+123
View File
@@ -0,0 +1,123 @@
import { execSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import { CodeGraph } from '../../src/index.js';
import { scoreSearchNodes, scoreFindRelevantContext } from './scoring.js';
import { testCases } from './test-cases.js';
import type { EvalReport, EvalResult } from './types.js';
const codebasePath = process.env.EVAL_CODEBASE || process.argv[2];
if (!codebasePath) {
console.error('Usage: EVAL_CODEBASE=/path/to/codebase npx tsx __tests__/evaluation/runner.ts');
console.error(' or: npx tsx __tests__/evaluation/runner.ts /path/to/codebase');
process.exit(1);
}
const resolvedPath = path.resolve(codebasePath);
if (!fs.existsSync(path.join(resolvedPath, '.codegraph', 'codegraph.db'))) {
console.error(`No .codegraph/codegraph.db found at ${resolvedPath}`);
process.exit(1);
}
let codegraphSha = 'unknown';
try {
codegraphSha = execSync('git rev-parse --short HEAD', { encoding: 'utf-8' }).trim();
} catch {}
console.log(`\nCodeGraph Eval — ${path.basename(resolvedPath)}`);
console.log(`Codebase: ${resolvedPath}`);
console.log(`Commit: ${codegraphSha}`);
console.log(`Cases: ${testCases.length}`);
console.log('');
async function run() {
const cg = CodeGraph.openSync(resolvedPath);
const results: EvalResult[] = [];
for (const tc of testCases) {
const start = performance.now();
if (tc.api === 'searchNodes') {
const searchResults = cg.searchNodes(tc.query, {
limit: 10,
kinds: tc.kinds,
...(tc.options as Record<string, unknown>),
});
const latency = performance.now() - start;
const result = scoreSearchNodes(tc.id, tc.expectedSymbols, searchResults, latency);
results.push(result);
} else {
const subgraph = await cg.findRelevantContext(tc.query, {
searchLimit: 8,
traversalDepth: 3,
maxNodes: 80,
minScore: 0.2,
...(tc.options as Record<string, unknown>),
});
const latency = performance.now() - start;
const result = scoreFindRelevantContext(tc.id, tc.expectedSymbols, subgraph, latency);
results.push(result);
}
}
cg.close();
// Print results table
const maxIdLen = Math.max(...results.map((r) => r.caseId.length));
for (const r of results) {
const status = r.pass ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
const id = r.caseId.padEnd(maxIdLen);
const recall = `recall=${r.recall.toFixed(2)}`;
const extra =
r.edgeDensity !== undefined
? `density=${r.edgeDensity.toFixed(2)}`
: `mrr=${r.mrr.toFixed(2)}`;
const latency = `${Math.round(r.latencyMs)}ms`;
console.log(` ${id} ${status} ${recall} ${extra} ${latency}`);
if (r.missedSymbols.length > 0) {
console.log(` ${' '.repeat(maxIdLen)} missed: ${r.missedSymbols.join(', ')}`);
}
}
// Summary
const passed = results.filter((r) => r.pass).length;
const failed = results.length - passed;
const meanRecall = results.reduce((s, r) => s + r.recall, 0) / results.length;
const mrrResults = results.filter((r) => r.mrr > 0 || r.caseId.startsWith('search-'));
const meanMRR =
mrrResults.length > 0 ? mrrResults.reduce((s, r) => s + r.mrr, 0) / mrrResults.length : 0;
console.log('');
const summaryColor = failed === 0 ? '\x1b[32m' : '\x1b[33m';
console.log(
`${summaryColor}SUMMARY: ${passed}/${results.length} passed | recall=${meanRecall.toFixed(2)} | mrr=${meanMRR.toFixed(2)}\x1b[0m`
);
// Save JSON report
const report: EvalReport = {
timestamp: new Date().toISOString(),
codebasePath: resolvedPath,
codegraphSha,
summary: { total: results.length, passed, failed, meanRecall, meanMRR },
results,
};
const resultsDir = path.join(__dirname, 'results');
fs.mkdirSync(resultsDir, { recursive: true });
const reportFile = path.join(
resultsDir,
`${new Date().toISOString().replace(/[:.]/g, '-')}.json`
);
fs.writeFileSync(reportFile, JSON.stringify(report, null, 2));
console.log(`\nReport saved: ${reportFile}`);
process.exit(failed > 0 ? 1 : 0);
}
run().catch((err) => {
console.error(err);
process.exit(1);
});
+82
View File
@@ -0,0 +1,82 @@
import type { EvalResult } from './types.js';
export const PASS_THRESHOLD = 0.5;
export function scoreSearchNodes(
caseId: string,
expectedSymbols: string[],
results: Array<{ node: { name: string }; score: number }>,
latencyMs: number
): EvalResult {
const expectedLower = expectedSymbols.map((s) => s.toLowerCase());
const resultNames = results.map((r) => r.node.name.toLowerCase());
const found: string[] = [];
const missed: string[] = [];
let firstRank = 0;
for (let i = 0; i < expectedLower.length; i++) {
const idx = resultNames.indexOf(expectedLower[i]);
if (idx !== -1) {
found.push(expectedSymbols[i]);
if (firstRank === 0) firstRank = idx + 1;
} else {
missed.push(expectedSymbols[i]);
}
}
const recall = expectedSymbols.length > 0 ? found.length / expectedSymbols.length : 0;
const mrr = firstRank > 0 ? 1 / firstRank : 0;
return {
caseId,
pass: recall >= PASS_THRESHOLD,
recall,
mrr,
foundSymbols: found,
missedSymbols: missed,
latencyMs,
};
}
export function scoreFindRelevantContext(
caseId: string,
expectedSymbols: string[],
subgraph: { nodes: Map<string, { name: string }>; edges: unknown[]; roots: string[] },
latencyMs: number
): EvalResult {
const expectedLower = new Set(expectedSymbols.map((s) => s.toLowerCase()));
const nodeNames = new Set<string>();
for (const node of subgraph.nodes.values()) {
nodeNames.add(node.name.toLowerCase());
}
const found: string[] = [];
const missed: string[] = [];
for (const sym of expectedSymbols) {
if (nodeNames.has(sym.toLowerCase())) {
found.push(sym);
} else {
missed.push(sym);
}
}
const recall = expectedSymbols.length > 0 ? found.length / expectedSymbols.length : 0;
const nodeCount = subgraph.nodes.size;
const edgeCount = subgraph.edges.length;
const edgeDensity = nodeCount > 0 ? edgeCount / nodeCount : 0;
return {
caseId,
pass: recall >= PASS_THRESHOLD,
recall,
mrr: 0,
foundSymbols: found,
missedSymbols: missed,
nodeCount,
edgeCount,
edgeDensity,
latencyMs,
};
}
+93
View File
@@ -0,0 +1,93 @@
import type { EvalTestCase } from './types.js';
export const testCases: EvalTestCase[] = [
// === searchNodes: Symbol Lookup Precision ===
{
id: 'search-class-exact',
query: 'TransportService',
api: 'searchNodes',
expectedSymbols: ['TransportService'],
kinds: ['class'],
},
{
id: 'search-method-qualified',
query: 'TransportService sendRequest',
api: 'searchNodes',
expectedSymbols: ['sendRequest'],
kinds: ['method'],
},
{
id: 'search-interface',
query: 'ActionListener',
api: 'searchNodes',
expectedSymbols: ['ActionListener'],
kinds: ['interface'],
},
{
id: 'search-enum',
query: 'RestStatus',
api: 'searchNodes',
expectedSymbols: ['RestStatus'],
kinds: ['enum'],
},
{
id: 'search-exception',
query: 'SearchPhaseExecutionException',
api: 'searchNodes',
expectedSymbols: ['SearchPhaseExecutionException'],
kinds: ['class'],
},
{
id: 'search-nested-class',
query: 'Engine Index',
api: 'searchNodes',
expectedSymbols: ['Index'],
kinds: ['class'],
},
// === findRelevantContext: Exploration Quality ===
{
id: 'explore-rest-layer',
query: 'How does the REST layer handle HTTP requests?',
api: 'findRelevantContext',
expectedSymbols: ['RestController', 'RestHandler', 'BaseRestHandler', 'RestRequest'],
options: { searchLimit: 8, traversalDepth: 3, maxNodes: 80, minScore: 0.2 },
},
{
id: 'explore-search-execution',
query: 'How does search execution work from request to shard?',
api: 'findRelevantContext',
expectedSymbols: ['TransportSearchAction', 'AbstractSearchAsyncAction', 'QueryPhase', 'FetchPhase'],
options: { searchLimit: 8, traversalDepth: 3, maxNodes: 80, minScore: 0.2 },
},
{
id: 'explore-bulk-indexing',
query: 'How does bulk indexing work?',
api: 'findRelevantContext',
expectedSymbols: ['TransportBulkAction', 'BulkRequest', 'BulkResponse'],
options: { searchLimit: 8, traversalDepth: 3, maxNodes: 80, minScore: 0.2 },
},
{
id: 'explore-shard-allocation',
query: 'How does shard rebalancing and allocation work?',
api: 'findRelevantContext',
expectedSymbols: ['AllocationService', 'BalancedShardsAllocator'],
options: { searchLimit: 8, traversalDepth: 3, maxNodes: 80, minScore: 0.2 },
},
{
id: 'explore-transport-search',
query: 'How does TransportService connect to SearchTransportService?',
api: 'findRelevantContext',
expectedSymbols: ['TransportService', 'SearchTransportService'],
options: { searchLimit: 8, traversalDepth: 3, maxNodes: 80, minScore: 0.2 },
},
{
id: 'explore-engine-implementations',
query: 'What are the Engine implementations for indexing?',
api: 'findRelevantContext',
expectedSymbols: ['InternalEngine', 'ReadOnlyEngine', 'Engine'],
options: { searchLimit: 8, traversalDepth: 3, maxNodes: 80, minScore: 0.2 },
},
];
+37
View File
@@ -0,0 +1,37 @@
import type { NodeKind } from '../../src/types.js';
export interface EvalTestCase {
id: string;
query: string;
api: 'searchNodes' | 'findRelevantContext';
expectedSymbols: string[];
kinds?: NodeKind[];
options?: Record<string, unknown>;
}
export interface EvalResult {
caseId: string;
pass: boolean;
recall: number;
mrr: number;
foundSymbols: string[];
missedSymbols: string[];
nodeCount?: number;
edgeCount?: number;
edgeDensity?: number;
latencyMs: number;
}
export interface EvalReport {
timestamp: string;
codebasePath: string;
codegraphSha: string;
summary: {
total: number;
passed: number;
failed: number;
meanRecall: number;
meanMRR: number;
};
results: EvalResult[];
}