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 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822
2026-10-21 23:32:35 -07:00
committed by GitHub
parent ef5ab1f1f2
commit c8ed328538
3 changed files with 241 additions and 25 deletions
+118 -2
View File
@@ -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<Record<string, readonly string[]>>;
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]);
});
+19 -23
View File
@@ -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<string, FunctionSnapshot>;
readonly reached: ReadonlyMap<string, number>;
/**
* 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<string, number>,
) => ({
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,
};
@@ -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()]);
}
});