From c8ed32853881b08aa83459d4da55a28a4e09b0c8 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 21 Oct 2026 23:32:35 -0700 Subject: [PATCH] fix(call-path): count only expanded edges as explored (#6256) build_call_path summed the out-degree of every reached function, so a terminal goal's callees counted as explored even though the forward search never expands the goal. Count explored edges and depth at the expansion boundary inside the search, drop the post-hoc summary and the now-unused out-degree helper, and reuse the schema-owned explored type. Fixes #5906 Co-authored-by: Claude Opus 5.5 --- src/domain/callPath.test.ts | 120 +++++++++++++++++- src/domain/callPath.ts | 42 +++--- .../mcp/callPathSearchExtentMcp.test.ts | 104 +++++++++++++++ 3 files changed, 241 insertions(+), 25 deletions(-) create mode 100644 tests/boundary/mcp/callPathSearchExtentMcp.test.ts diff --git a/src/domain/callPath.test.ts b/src/domain/callPath.test.ts index 048cb63b41..75ba7a92fb 100644 --- a/src/domain/callPath.test.ts +++ b/src/domain/callPath.test.ts @@ -269,7 +269,7 @@ describe("call path topology and capacity", () => { search_scope: { exhaustive: true }, }); expect(result.limitations).toContain(goalLimitation); - expect(result.explored.nodes).toBe(2); + expect(result.explored).toEqual({ nodes: 2, edges: 1, depth_reached: 1 }); expect(result.limitations).not.toContain( "No analyze_function Evidence covers reachable function 0x5000", ); @@ -373,6 +373,122 @@ describe("directional call relationship coverage", () => { ); }); +describe("call path search extent", () => { + type Graph = Readonly>; + const search = (graph: Graph, goal: string, start = "0x1000") => + run( + Object.entries(graph).map(([address, callees]) => + observe(address, callees), + ), + { start: { address: start }, goal: { address: goal } }, + ); + const withoutGoalDossier = (graph: Graph, goal: string): Graph => + Object.fromEntries( + Object.entries(graph).filter(([address]) => address !== goal), + ); + + // Each case states what the forward search discovers and expands. A terminal + // goal is discovered but never expanded, so its callees are not explored. + it.each([ + { + label: "goal at depth one with an outgoing edge", + graph: { "0x1000": ["0x4000"], "0x4000": ["0x5000"] }, + goal: "0x4000", + status: "found", + explored: { nodes: 2, edges: 1, depth_reached: 1 }, + }, + { + label: "zero hops from a start with callees", + graph: { "0x1000": ["0x2000"] }, + goal: "0x1000", + status: "found", + explored: { nodes: 1, edges: 0, depth_reached: 0 }, + }, + { + label: "zero hops from a self-recursive start", + graph: { "0x1000": ["0x1000"] }, + goal: "0x1000", + status: "found", + explored: { nodes: 1, edges: 0, depth_reached: 0 }, + }, + { + label: "self-recursive goal", + graph: { "0x1000": ["0x4000"], "0x4000": ["0x4000"] }, + goal: "0x4000", + status: "found", + explored: { nodes: 2, edges: 1, depth_reached: 1 }, + }, + { + label: "deep goal calling back into and beyond the closure", + graph: { + "0x1000": ["0x2000", "0x3000"], + "0x2000": ["0x4000"], + "0x3000": [], + "0x4000": ["0x1000", "0x6000"], + }, + goal: "0x4000", + status: "found", + explored: { nodes: 4, edges: 3, depth_reached: 2 }, + }, + { + label: "goal with several expanded callers", + graph: { + "0x1000": ["0x2000", "0x3000"], + "0x2000": ["0x4000"], + "0x3000": ["0x4000"], + "0x4000": ["0x5000"], + }, + goal: "0x4000", + status: "found", + explored: { nodes: 4, edges: 4, depth_reached: 2 }, + }, + { + label: "expanded cycle without the goal", + graph: { "0x1000": ["0x2000"], "0x2000": ["0x1000", "0x2000"] }, + goal: "0x4000", + status: "not_found", + explored: { nodes: 2, edges: 3, depth_reached: 1 }, + }, + { + label: "unreachable goal with its own dossier", + graph: { "0x1000": ["0x2000"], "0x2000": [], "0x4000": ["0x5000"] }, + goal: "0x4000", + status: "not_found", + explored: { nodes: 2, edges: 1, depth_reached: 1 }, + }, + { + label: "reachable function without Evidence", + graph: { "0x1000": ["0x2000"] }, + goal: "0x4000", + status: "unknown", + explored: { nodes: 2, edges: 1, depth_reached: 1 }, + }, + ])( + "counts only expanded edges: $label", + ({ graph, goal, status, explored }) => { + expect(search(graph, goal)).toMatchObject({ status, explored }); + }, + ); + + it("keeps explored extent independent of the terminal goal's dossier", () => { + for (const graph of [ + { "0x1000": ["0x4000"], "0x4000": ["0x5000", "0x6000"] }, + { "0x1000": ["0x4000"], "0x4000": ["0x4000", "0x1000"] }, + { + "0x1000": ["0x2000", "0x3000"], + "0x2000": ["0x4000"], + "0x3000": ["0x4000"], + "0x4000": ["0x2000", "0x7000", "0x8000"], + }, + ]) { + const supplied = search(graph, "0x4000"); + const omitted = search(withoutGoalDossier(graph, "0x4000"), "0x4000"); + expect(supplied.status).toBe("found"); + expect(supplied.explored).toEqual(omitted.explored); + } + }); +}); + describe("explicit call relationship coverage", () => { it("accumulates 150,000 partial reasons without argument-stack exhaustion", () => { const result = run([ @@ -436,7 +552,7 @@ describe("explicit call relationship coverage", () => { expect(result).toMatchObject({ status: "found", search_scope: { exhaustive: true }, - explored: { nodes: 2 }, + explored: { nodes: 2, edges: 1, depth_reached: 1 }, }); expect(result.limitations).toEqual([reason]); }); diff --git a/src/domain/callPath.ts b/src/domain/callPath.ts index 0591be0323..8c70707e64 100644 --- a/src/domain/callPath.ts +++ b/src/domain/callPath.ts @@ -35,10 +35,6 @@ class CallGraph { return this.successors.has(node); } - outDegree(node: string): number { - return this.successors.get(node)?.size ?? 0; - } - outNeighbors(node: string): string[] { return [...(this.successors.get(node) ?? [])]; } @@ -72,7 +68,12 @@ const distancesToGoal = ( interface SearchState { readonly graph: CallGraph; readonly snapshots: ReadonlyMap; - readonly reached: ReadonlyMap; + /** + * Forward-search extent, counted where the search does its work: functions + * discovered (including unexpanded terminals), direct-callee edges examined + * while expanding non-goal functions, and the greatest discovery depth. + */ + readonly explored: CallPathResult["explored"]; readonly exhaustive: boolean; readonly limitations: readonly string[]; } @@ -105,7 +106,7 @@ export const buildCallPath = (input: CallPathInput): CallPathResult => { const resultContext = { start: parsed.start.address, goal: parsed.goal.address, - explored: summarizeSearch(graph, search.reached), + explored: search.explored, evidence_links: uniqueEvidence(snapshots.values()), limitations: [...new Set(limitations)].sort(compareUnicodeCodePoints), }; @@ -127,21 +128,6 @@ export const buildCallPath = (input: CallPathInput): CallPathResult => { }); }; -const summarizeSearch = ( - graph: CallGraph, - reached: ReadonlyMap, -) => ({ - nodes: reached.size, - edges: [...reached.keys()].reduce( - (count, node) => count + (graph.hasNode(node) ? graph.outDegree(node) : 0), - 0, - ), - depth_reached: [...reached.values()].reduce( - (maximum, depth) => Math.max(maximum, depth), - 0, - ), -}); - const prepareCallGraph = (input: CallPathInput) => { const snapshots = parseSnapshots(input.functions); assertCompatible(snapshots); @@ -222,7 +208,7 @@ const inspectSearch = ({ return { graph, snapshots, - reached: new Map(), + explored: { nodes: 0, edges: 0, depth_reached: 0 }, exhaustive: false, limitations: [ `No analyze_function Evidence was supplied for start ${start}`, @@ -232,10 +218,13 @@ const inspectSearch = ({ const queue = [start]; const limitations: string[] = []; let incompleteExpandedCoverage = false; + let expandedEdges = 0; + let depthReached = 0; for (let index = 0; index < queue.length; index += 1) { const node = queue[index]; if (node === undefined) continue; const depth = reached.get(node) ?? 0; + depthReached = Math.max(depthReached, depth); const snapshot = snapshots.get(node); if (snapshot !== undefined) { const coverage = snapshot.evidence.relationship_coverage; @@ -280,6 +269,9 @@ const inspectSearch = ({ continue; } const neighbors = graph.outNeighbors(node).sort(compareUnicodeCodePoints); + // Count only edges this expansion examines; a terminal goal's outgoing + // relationships never reach this point and so never count as explored. + expandedEdges += neighbors.length; for (const neighbor of neighbors) if (!reached.has(neighbor)) { reached.set(neighbor, depth + 1); @@ -289,7 +281,11 @@ const inspectSearch = ({ return { graph, snapshots, - reached, + explored: { + nodes: reached.size, + edges: expandedEdges, + depth_reached: depthReached, + }, exhaustive: !incompleteExpandedCoverage, limitations, }; diff --git a/tests/boundary/mcp/callPathSearchExtentMcp.test.ts b/tests/boundary/mcp/callPathSearchExtentMcp.test.ts new file mode 100644 index 0000000000..e0f444e15f --- /dev/null +++ b/tests/boundary/mcp/callPathSearchExtentMcp.test.ts @@ -0,0 +1,104 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { expect, it } from "vitest"; + +import { FUNCTION_COMPARISON_EXAMPLE } from "../../../src/contracts/functionComparisonExample.js"; +import { callPathResultSchema } from "../../../src/domain/callPathSchemas.js"; +import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; +import { functionDossierSchema } from "../../../src/domain/hopperValues.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; + +const observe = (address: string, callees: readonly string[]) => { + const template = FUNCTION_COMPARISON_EXAMPLE.left; + if (template.subject === null) + throw new Error("Expected artifact-bound fixture"); + const dossier = functionDossierSchema.parse(template.normalized_result); + return createEvidence( + { + path: template.subject.local_path, + sha256: template.subject.digest.sha256, + format: template.subject.format, + }, + template.provider, + { + operation: "analyze_function", + parameters: { procedure: address }, + result: { + ...dossier, + procedure: { ...dossier.procedure, address }, + callees: callees.map((callee) => ({ + address: callee, + name: `fn_${callee}`, + classification: null, + body: dossier.procedure.body, + })), + callers: [], + unresolved_calls: [], + limitations: [], + }, + confidence: template.confidence, + authority: template.authority, + relationshipCoverage: { status: "complete" }, + }, + ); +}; + +it("publishes explored extent without the terminal goal's callees", async () => { + const session = createTestBinarySession(() => { + throw new Error("Offline call paths must not launch a provider"); + }); + const server = createServer({ kind: "session", session }); + const client = new Client({ name: "call-path-search-extent", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const start = observe("0x1000", ["0x4000"]); + const goal = observe("0x4000", ["0x5000", "0x1000"]); + for (const evidence of [start, goal]) + expect(session.recordEvidence(evidence).ok).toBe(true); + for (const { functions, target, hops, explored } of [ + { + functions: [start], + target: "0x4000", + hops: 1, + explored: { nodes: 2, edges: 1, depth_reached: 1 }, + }, + { + functions: [start, goal], + target: "0x4000", + hops: 1, + explored: { nodes: 2, edges: 1, depth_reached: 1 }, + }, + { + functions: [start], + target: "0x1000", + hops: 0, + explored: { nodes: 1, edges: 0, depth_reached: 0 }, + }, + ]) { + const reply = await client.callTool({ + name: "build_call_path", + arguments: { + functions, + start: { address: "0x1000" }, + goal: { address: target }, + }, + }); + if (reply.isError) throw new Error(JSON.stringify(reply)); + const result = callPathResultSchema.parse( + parseEvidence(reply.structuredContent).normalized_result, + ); + expect(result).toMatchObject({ + status: "found", + shortest_hops: hops, + search_scope: { exhaustive: true }, + explored, + limitations: [], + }); + } + } finally { + await Promise.allSettled([session.close(), client.close(), server.close()]); + } +});