explore-relevance-scoring.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. /**
  2. * Relevance scoring for `codegraph_explore` — CG-10 / #1500.
  3. *
  4. * The failure this pins: a file that merely NAME-COLLIDES with the query used to
  5. * score the same per match as the file that answers it, because every match in a
  6. * tier counted the same regardless of what was matched. Three
  7. * `scripts/agent-eval/*.mjs` harnesses took 63% of this repo's own "how does
  8. * explore allocate its output budget across files" response on nothing but a
  9. * local `const explore` and a `const BUDGET`.
  10. *
  11. * Four levers, one fixture family each:
  12. * 1. KIND WEIGHT — a match on a function/class outweighs one on a
  13. * variable/constant/parameter.
  14. * 2. ISOLATION — a weak-kind symbol nothing calls or references is a
  15. * pure collision and is demoted much harder.
  16. * 3. RELATIVE FLOOR — admission scales with the best file's score instead of
  17. * an absolute `>= 3`, capped so one direct match always
  18. * gets in and floored so a diffuse query keeps its spread.
  19. * 4. RANK PENALTY — generated and test/i18n files are discounted on BOTH
  20. * the score and the graph mass (the sort's primary key),
  21. * not merely tie-broken at equal score.
  22. *
  23. * Each fixture is a whole indexed project because the scoring reads the graph
  24. * (usage edges, RWR mass, the generated flag) — there is no seam to unit-test
  25. * the comparator against, and mocking one would pin the mock, not the behavior.
  26. */
  27. import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  28. import * as fs from 'fs';
  29. import * as path from 'path';
  30. import * as os from 'os';
  31. import CodeGraph from '../src/index';
  32. import { ToolHandler, RELEVANCE_KIND_WEIGHT } from '../src/mcp/tools';
  33. import { attributeSourceBytes } from '../src/mcp/explore-diagnostics';
  34. /** Build + index a throwaway project from a `{ relPath: source }` map. */
  35. async function buildProject(
  36. prefix: string,
  37. files: Record<string, string>,
  38. ): Promise<{ dir: string; cg: CodeGraph; handler: ToolHandler }> {
  39. const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
  40. for (const [rel, body] of Object.entries(files)) {
  41. const abs = path.join(dir, rel);
  42. fs.mkdirSync(path.dirname(abs), { recursive: true });
  43. fs.writeFileSync(abs, body.trimStart());
  44. }
  45. const cg = CodeGraph.initSync(dir);
  46. await cg.indexAll();
  47. return { dir, cg, handler: new ToolHandler(cg) };
  48. }
  49. const cleanup = (dir: string, cg?: CodeGraph) => {
  50. if (cg) cg.destroy();
  51. if (dir && fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
  52. };
  53. /**
  54. * Where a file's source section appears in the response. Sections are emitted in
  55. * final rank order, so this is the ranking assertion — which is what CG-10 owns.
  56. * How many BYTES each ranked file then gets is CG-12's (`maxCharsPerFile` and the
  57. * render loop still spend by file size, so a large low-ranked file can still
  58. * out-byte a small high-ranked one).
  59. */
  60. const rankOf = (text: string, filePath: string): number => {
  61. const at = text.indexOf('**`' + filePath + '`**');
  62. if (at < 0) return Number.POSITIVE_INFINITY;
  63. return text.slice(0, at).split('**`').length;
  64. };
  65. describe('RELEVANCE_KIND_WEIGHT', () => {
  66. it('ranks callables and types above members, and members above locals', () => {
  67. const callables = ['function', 'method', 'class', 'struct', 'interface', 'route', 'component'];
  68. for (const kind of callables) expect(RELEVANCE_KIND_WEIGHT[kind]).toBe(1);
  69. for (const member of ['property', 'field', 'enum_member']) {
  70. expect(RELEVANCE_KIND_WEIGHT[member]!).toBeLessThan(RELEVANCE_KIND_WEIGHT.function!);
  71. expect(RELEVANCE_KIND_WEIGHT[member]!).toBeGreaterThan(RELEVANCE_KIND_WEIGHT.parameter!);
  72. }
  73. // The #1500 kinds: incidental until the graph corroborates them.
  74. for (const weak of ['constant', 'variable', 'parameter']) {
  75. expect(RELEVANCE_KIND_WEIGHT[weak]!).toBeLessThan(0.5);
  76. }
  77. expect(RELEVANCE_KIND_WEIGHT.parameter!).toBeLessThan(RELEVANCE_KIND_WEIGHT.variable!);
  78. });
  79. });
  80. describe('explore relevance scoring — incidental name collisions (#1500)', () => {
  81. let dir: string;
  82. let cg: CodeGraph;
  83. let handler: ToolHandler;
  84. // Shape: one file DEFINES the dispatch mechanism; three unrelated scripts each
  85. // declare a lone unused `dispatch`/`registry` binding. Before CG-10 all four
  86. // cleared the floor and the three small scripts, shipping whole, took most of
  87. // the envelope from the large real file, which got clipped.
  88. beforeAll(async () => {
  89. const noise = (n: number) => `
  90. const dispatch = ${n};
  91. const registry = 'unused-${n}';
  92. function unrelated${n}Helper(value) {
  93. return value + ${n};
  94. }
  95. `;
  96. ({ dir, cg, handler } = await buildProject('codegraph-cg10-collide-', {
  97. 'src/dispatcher.js': `
  98. import { lookupHandler } from './registry.js';
  99. export function dispatch(event) {
  100. const handler = lookupHandler(event.type);
  101. if (!handler) return null;
  102. return runHandler(handler, event);
  103. }
  104. export function runHandler(handler, event) {
  105. return handler(event.payload);
  106. }
  107. `,
  108. 'src/registry.js': `
  109. const handlers = new Map();
  110. export function registerHandler(type, fn) {
  111. handlers.set(type, fn);
  112. }
  113. export function lookupHandler(type) {
  114. return handlers.get(type);
  115. }
  116. `,
  117. 'scripts/report-a.js': noise(1),
  118. 'scripts/report-b.js': noise(2),
  119. 'scripts/report-c.js': noise(3),
  120. }));
  121. }, 120_000);
  122. afterAll(() => cleanup(dir, cg));
  123. const explore = async (query: string) => {
  124. const result = await handler.execute('codegraph_explore', { query });
  125. const text = result.content?.[0]?.text ?? '';
  126. return { text, bytes: attributeSourceBytes(text) };
  127. };
  128. it('keeps files whose only match is an unused local out of the response', async () => {
  129. const { bytes } = await explore('how does dispatch route an event to its handler');
  130. for (const noiseFile of ['scripts/report-a.js', 'scripts/report-b.js', 'scripts/report-c.js']) {
  131. expect(bytes.get(noiseFile) ?? 0, `${noiseFile} must not reach the envelope`).toBe(0);
  132. }
  133. });
  134. it('spends every delivered source byte on the files that define the mechanism', async () => {
  135. const { bytes } = await explore('how does dispatch route an event to its handler');
  136. let answer = 0;
  137. let noise = 0;
  138. for (const [file, n] of bytes) {
  139. if (file.startsWith('src/')) answer += n;
  140. else noise += n;
  141. }
  142. expect(answer).toBeGreaterThan(0);
  143. expect(noise).toBe(0);
  144. expect(bytes.get('src/dispatcher.js') ?? 0).toBeGreaterThan(0);
  145. });
  146. it('still answers when the collision is the ONLY thing that matched', async () => {
  147. // Guard against over-correction: querying the noise term alone must not
  148. // produce an empty response. Under-serving costs the agent a round-trip, so
  149. // the floor's backfill has to keep the best of what matched.
  150. const { text } = await explore('unrelated2Helper');
  151. expect(text).not.toContain('No relevant code found');
  152. expect(text).toContain('unrelated2Helper');
  153. });
  154. });
  155. describe('explore relevance scoring — generated source is penalized, not tie-broken', () => {
  156. let dir: string;
  157. let cg: CodeGraph;
  158. let handler: ToolHandler;
  159. // The #1500 shape in miniature: the generated layer collides on every query
  160. // term AND carries more call-graph mass than the hand-written use-case, so a
  161. // generated-as-tiebreak-only rule leaves it ranked first.
  162. beforeAll(async () => {
  163. ({ dir, cg, handler } = await buildProject('codegraph-cg10-generated-', {
  164. 'go.mod': 'module example.com/billing\n\ngo 1.22\n',
  165. 'internal/usecase/billing/invoice.go': `
  166. package billing
  167. // Service runs the month-end invoicing workflow.
  168. type Service struct {
  169. store Store
  170. }
  171. // RunInvoiceCycle is the hand-written business rule the question is about.
  172. func (s *Service) RunInvoiceCycle(month string) error {
  173. lines := s.CollectInvoiceLines(month)
  174. total := s.CalculateInvoiceTotal(lines)
  175. return s.store.Save(month, total)
  176. }
  177. func (s *Service) CollectInvoiceLines(month string) []int {
  178. return []int{1, 2, 3}
  179. }
  180. func (s *Service) CalculateInvoiceTotal(lines []int) int {
  181. sum := 0
  182. for _, l := range lines {
  183. sum += l
  184. }
  185. return sum
  186. }
  187. `,
  188. 'internal/usecase/billing/store.go': `
  189. package billing
  190. type Store interface {
  191. Save(month string, total int) error
  192. }
  193. `,
  194. // Ordinary filename — ONLY the content banner betrays it (the CG-5 case).
  195. 'internal/gen/billing/invoice.go': `
  196. // Code generated by billingkit. DO NOT EDIT.
  197. package gen
  198. type InvoiceRow struct {
  199. Month string
  200. Total int
  201. }
  202. type InvoiceCreateRequest struct {
  203. Month string
  204. }
  205. func CreateInvoice(req InvoiceCreateRequest) InvoiceRow {
  206. return BuildInvoice(req.Month, 0)
  207. }
  208. func BuildInvoice(month string, total int) InvoiceRow {
  209. return InvoiceRow{Month: month, Total: total}
  210. }
  211. func CalculateInvoiceTotal(rows []InvoiceRow) int {
  212. sum := 0
  213. for _, r := range rows {
  214. sum += r.Total
  215. }
  216. return sum
  217. }
  218. func ListInvoices(month string) []InvoiceRow {
  219. return []InvoiceRow{BuildInvoice(month, 0)}
  220. }
  221. func CollectInvoiceLines(month string) []InvoiceRow {
  222. return ListInvoices(month)
  223. }
  224. func RunInvoiceCycle(month string) InvoiceRow {
  225. rows := CollectInvoiceLines(month)
  226. return BuildInvoice(month, CalculateInvoiceTotal(rows))
  227. }
  228. `,
  229. }));
  230. }, 120_000);
  231. afterAll(() => cleanup(dir, cg));
  232. it('indexes the ordinary-named generated file via its content banner', () => {
  233. expect(cg.getFile('internal/gen/billing/invoice.go')?.generated).toBe(true);
  234. expect(cg.getFile('internal/usecase/billing/invoice.go')?.generated).toBe(false);
  235. });
  236. it('ranks the hand-written workflow above its generated twin', async () => {
  237. const result = await handler.execute('codegraph_explore', {
  238. query: 'how does the invoice cycle collect lines and calculate the total',
  239. });
  240. const text = result.content?.[0]?.text ?? '';
  241. // The generated file collides on EVERY query term and carries call-graph
  242. // mass of its own, so with generated status as a mere tiebreak-at-equal-score
  243. // it ranked first. The penalty scales its score AND its graph mass, which is
  244. // the key the comparator actually sorts on.
  245. const handWritten = rankOf(text, 'internal/usecase/billing/invoice.go');
  246. const generated = rankOf(text, 'internal/gen/billing/invoice.go');
  247. expect(handWritten).toBeLessThan(generated);
  248. expect(attributeSourceBytes(text).get('internal/usecase/billing/invoice.go') ?? 0)
  249. .toBeGreaterThan(0);
  250. });
  251. });
  252. describe('explore relevance scoring — test files never buy the envelope', () => {
  253. let dir: string;
  254. let cg: CodeGraph;
  255. let handler: ToolHandler;
  256. // A repo-ROOT `test/` directory — the shape express and most of npm/Go use.
  257. // The old detector anchored on a leading `/`, so `test/x.js` never matched it
  258. // and express's routing question spent 59% of its envelope on three test files.
  259. beforeAll(async () => {
  260. const spec = (n: number) => `
  261. const { parseRoute } = require('../lib/router.js');
  262. describe('parseRoute ${n}', () => {
  263. it('parses a route ${n}', () => {
  264. parseRoute('/a/${n}');
  265. });
  266. it('parses another route ${n}', () => {
  267. parseRoute('/b/${n}');
  268. });
  269. });
  270. `;
  271. ({ dir, cg, handler } = await buildProject('codegraph-cg10-lowvalue-', {
  272. 'lib/router.js': `
  273. exports.parseRoute = function parseRoute(pathname) {
  274. const segments = pathname.split('/').filter(Boolean);
  275. return { segments, matched: matchRoute(segments) };
  276. };
  277. function matchRoute(segments) {
  278. return segments.length > 0;
  279. }
  280. `,
  281. 'lib/dispatch.js': `
  282. const { parseRoute } = require('./router.js');
  283. exports.dispatchRoute = function dispatchRoute(pathname) {
  284. return parseRoute(pathname);
  285. };
  286. `,
  287. 'test/router.raw.js': spec(1),
  288. 'test/router.json.js': spec(2),
  289. 'test/router.text.js': spec(3),
  290. }));
  291. }, 120_000);
  292. afterAll(() => cleanup(dir, cg));
  293. it('excludes a repo-root test/ directory from the envelope', async () => {
  294. const result = await handler.execute('codegraph_explore', {
  295. query: 'how does the router parse and dispatch a route',
  296. });
  297. const bytes = attributeSourceBytes(result.content?.[0]?.text ?? '');
  298. for (const [file, n] of bytes) {
  299. expect(n === 0 || !file.startsWith('test/'), `${file} took ${n} chars`).toBe(true);
  300. }
  301. expect(bytes.get('lib/router.js') ?? 0).toBeGreaterThan(0);
  302. });
  303. it('still returns tests when the query is about them', async () => {
  304. const result = await handler.execute('codegraph_explore', {
  305. query: 'which tests cover parseRoute',
  306. });
  307. const text = result.content?.[0]?.text ?? '';
  308. expect(text).not.toContain('No relevant code found');
  309. });
  310. });