mirror of
https://github.com/morluto/rea
synced 2026-10-28 06:46:47 -04:00
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:
+118
-2
@@ -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
@@ -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()]);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user