From 5e4ec0fa1b678aa3bf20704ec9110224ba8c1676 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:31:07 +0800 Subject: [PATCH] perf: retain call path ancestry once per visited node --- src/application/CallPathTracing.ts | 47 +++--- .../enhancedTools.callPathsAndBatches.test.ts | 52 +++++++ tests/composition/callPathGraphOracle.test.ts | 147 ++++++++++++++++++ 3 files changed, 225 insertions(+), 21 deletions(-) create mode 100644 tests/composition/callPathGraphOracle.test.ts diff --git a/src/application/CallPathTracing.ts b/src/application/CallPathTracing.ts index 41d96a9b41..1fd6807b3d 100644 --- a/src/application/CallPathTracing.ts +++ b/src/application/CallPathTracing.ts @@ -35,11 +35,14 @@ export interface CallPathTraceInput { readonly direction: CallRelationshipDirection; } +interface TraceNode { + readonly address: string; + readonly depth: number; + readonly parent: TraceNode | undefined; +} + interface TraceState { - readonly visited: Map< - string, - { readonly depth: number; readonly path: string[] } - >; + readonly visited: Map; readonly queue: string[]; readonly edges: Map< string, @@ -64,7 +67,7 @@ interface AddressExpansion { readonly input: CallPathTraceInput; readonly state: TraceState; readonly address: string; - readonly current: { readonly depth: number; readonly path: string[] }; + readonly current: TraceNode; readonly signal?: AbortSignal; } @@ -123,7 +126,9 @@ export const traceCallPath = async ( }; const createTraceState = (input: CallPathTraceInput): TraceState => ({ - visited: new Map([[input.start, { depth: 0, path: [input.start] }]]), + visited: new Map([ + [input.start, { address: input.start, depth: 0, parent: undefined }], + ]), queue: [input.start], edges: new Map(), failures: [], @@ -195,23 +200,15 @@ const expandAddress = async ( relatedAddress, discoveryDepth: current.depth + 1, }); - const path = state.visited.get(relatedAddress)?.path ?? [ - ...current.path, - relatedAddress, - ]; - if (state.visited.has(relatedAddress)) { - if (relatedAddress === input.goal) { - state.traversalPath = path; - return ok(true); - } - continue; - } - state.visited.set(relatedAddress, { + if (state.visited.has(relatedAddress)) continue; + const discovered: TraceNode = { + address: relatedAddress, depth: current.depth + 1, - path, - }); + parent: current, + }; + state.visited.set(relatedAddress, discovered); if (relatedAddress === input.goal) { - state.traversalPath = path; + state.traversalPath = reconstructPath(discovered); return ok(true); } state.queue.push(relatedAddress); @@ -219,6 +216,14 @@ const expandAddress = async ( return ok(false); }; +/** Only the selected goal needs a path; each discovered node owns one parent link. */ +const reconstructPath = (goal: TraceNode): string[] => { + const path: string[] = []; + for (let node: TraceNode | undefined = goal; node; node = node.parent) + path.push(node.address); + return path.reverse(); +}; + const recordEdge = ( input: CallPathTraceInput, state: TraceState, diff --git a/tests/boundary/mcp/enhancedTools.callPathsAndBatches.test.ts b/tests/boundary/mcp/enhancedTools.callPathsAndBatches.test.ts index 24c2858019..3ad9f1e241 100644 --- a/tests/boundary/mcp/enhancedTools.callPathsAndBatches.test.ts +++ b/tests/boundary/mcp/enhancedTools.callPathsAndBatches.test.ts @@ -214,6 +214,58 @@ describe("enhanced MCP graph caveats", () => { }); }); +describe("trace_call_path MCP path selection", () => { + it.each(["forward", "backward"] as const)( + "selects the first shortest %s path through a diamond with cycles", + async (direction) => { + const expanded: string[] = []; + const related: Readonly> = { + "0x1": ["0x3", "0x2", "0x2"], + "0x2": ["0x1", "0x2", "0x4"], + "0x3": ["0x4"], + }; + const client = await connect({ + execute: async (name, arguments_) => { + if (name === "procedure_address") + return relationshipExecution(String(arguments_.procedure)); + expect(name).toBe( + direction === "forward" ? "procedure_callees" : "procedure_callers", + ); + const address = String(arguments_.procedure); + expanded.push(address); + return relationshipExecution(related[address] ?? [], [], { + status: "complete", + }); + }, + }); + const trace = await client.callTool({ + name: "trace_call_path", + arguments: { start: "0x1", goal: "0x4", direction }, + }); + expect(trace.isError).not.toBe(true); + expect(trace.structuredContent).toMatchObject({ + normalized_result: { + goal_status: "reached", + traversal_path: ["0x1", "0x2", "0x4"], + nodes: [ + { address: "0x1", depth: 0 }, + { address: "0x2", depth: 1 }, + { address: "0x3", depth: 1 }, + { address: "0x4", depth: 2 }, + ], + truncated: false, + failures: [], + relationship_coverage: { + "0x1": { status: "complete" }, + "0x2": { status: "complete" }, + }, + }, + }); + expect(expanded).toEqual(["0x1", "0x2"]); + }, + ); +}); + describe("trace_call_path MCP outcome semantics", () => { it("reports an unreachable goal as unknown when relationship coverage is partial", async () => { const client = await connect({ diff --git a/tests/composition/callPathGraphOracle.test.ts b/tests/composition/callPathGraphOracle.test.ts new file mode 100644 index 0000000000..35dbbb0a49 --- /dev/null +++ b/tests/composition/callPathGraphOracle.test.ts @@ -0,0 +1,147 @@ +import { expect, it } from "vitest"; +import { z } from "zod"; +import { createAnalysisExecution } from "../../src/application/AnalysisProvider.js"; +import { traceCallPath } from "../../src/application/CallPathTracing.js"; +import { ok } from "../../src/domain/result.js"; + +const traceSchema = z.object({ + goal_status: z.string(), + traversal_path: z.array(z.string()), + nodes: z.array(z.object({ address: z.string(), depth: z.number() })), + edges: z.array( + z.object({ + source_address: z.string(), + target_address: z.string(), + discovery_depth: z.number(), + }), + ), +}); +const addresses = ["0x1", "0x2", "0x3"]; + +/** Enumerate simple paths independently of the production breadth-first traversal. */ +const pathsFrom = ( + graph: ReadonlyMap, + path: string[], +): string[][] => [ + path, + ...(graph.get(path.at(-1)!) ?? []).flatMap((next) => + path.includes(next) ? [] : pathsFrom(graph, [...path, next]), + ), +]; + +it("matches a simple-path oracle for every three-node directed graph in both directions", async () => { + const possible = addresses.flatMap((from) => + addresses.filter((to) => to !== from).map((to) => [from, to] as const), + ); + for (let mask = 0; mask < 2 ** possible.length; mask++) { + const edges = possible.filter((_, index) => mask & (1 << index)); + for (const direction of ["forward", "backward"] as const) { + const graph = new Map( + addresses.map((address) => [ + address, + edges + .filter( + ([from, to]) => (direction === "forward" ? from : to) === address, + ) + .map(([from, to]) => (direction === "forward" ? to : from)), + ]), + ); + const paths = pathsFrom(graph, ["0x1"]).sort( + (a, b) => + a.length - b.length || + JSON.stringify(a).localeCompare(JSON.stringify(b)), + ); + const depth = (address: string) => + paths.find((path) => path.at(-1) === address)!.length - 1; + for (const goal of [undefined, ...addresses]) { + const expanded: string[] = []; + const result = await traceCallPath( + async (_, args) => ok(String(args.procedure)), + async (_, args) => { + const address = String(args.procedure); + expanded.push(address); + // Deliberately duplicated and reversed producer order. + const related = [...(graph.get(address) ?? [])].reverse(); + return ok( + createAnalysisExecution( + [...related, ...related], + { id: "oracle", name: "Oracle", version: null }, + { relationshipCoverage: { status: "complete" } }, + ), + ); + }, + { start: "0x1", goal, direction }, + ); + if (!result.ok) throw result.error; + const trace = traceSchema.parse(result.value); + const expected = + goal === undefined + ? [] + : (paths.find((path) => path.at(-1) === goal) ?? []); + expect(trace.traversal_path).toEqual(expected); + expect(trace.goal_status).toBe( + goal === undefined + ? "not_requested" + : expected.length > 0 + ? "reached" + : "not_reached", + ); + expect(new Set(expanded).size).toBe(expanded.length); + if (goal !== undefined) continue; + const reachable = new Set(paths.map((path) => path.at(-1)!)); + expect(trace.nodes).toEqual( + [...reachable] + .map((address) => ({ address, depth: depth(address) })) + .sort( + (a, b) => a.depth - b.depth || a.address.localeCompare(b.address), + ), + ); + expect(trace.edges).toEqual( + edges + .filter(([from, to]) => + reachable.has(direction === "forward" ? from : to), + ) + .map(([from, to]) => ({ + source_address: from, + target_address: to, + discovery_depth: depth(direction === "forward" ? from : to) + 1, + })) + .sort( + (a, b) => + a.discovery_depth - b.discovery_depth || + a.source_address.localeCompare(b.source_address) || + a.target_address.localeCompare(b.target_address), + ), + ); + } + } + } +}); + +it("reconstructs a deep chain iteratively and preserves its discovery depths", async () => { + const count = 5000; + const address = (index: number) => `0x${index.toString(16).padStart(4, "0")}`; + const result = await traceCallPath( + async (_, args) => ok(String(args.procedure)), + async (_, args) => { + const next = Number.parseInt(String(args.procedure).slice(2), 16) + 1; + return ok( + createAnalysisExecution( + next < count ? [address(next)] : [], + { id: "chain", name: "Chain", version: null }, + { relationshipCoverage: { status: "complete" } }, + ), + ); + }, + { start: address(0), goal: address(count - 1), direction: "forward" }, + ); + if (!result.ok) throw result.error; + const trace = traceSchema.parse(result.value); + expect(trace.traversal_path).toEqual( + Array.from({ length: count }, (_, index) => address(index)), + ); + expect(trace.nodes.at(-1)).toEqual({ + address: address(count - 1), + depth: count - 1, + }); +});