mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
perf: retain call path ancestry once per visited node
This commit is contained in:
1 parent
8aa40e9a61
commit
5e4ec0fa1b
3 files changed
+225
-21
No files matched your search
@@ -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<string, TraceNode>;
|
||||
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,
|
||||
|
||||
@@ -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<Record<string, readonly string[]>> = {
|
||||
"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({
|
||||
|
||||
@@ -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<string, readonly string[]>,
|
||||
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,
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user