From f90382756d4625d2a50b0cf6a4c30de038289b9a Mon Sep 17 00:00:00 2001 From: Iven Koo <73214171+IvenKooLab@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:06:50 +0800 Subject: [PATCH] fix(native): keep bounded trace edges and depth consistent (#2758) --- .../native/nativeInvestigationGraph.test.ts | 55 +++++++++++++++++++ src/domain/native/nativeInvestigationGraph.ts | 12 ++-- 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/src/domain/native/nativeInvestigationGraph.test.ts b/src/domain/native/nativeInvestigationGraph.test.ts index 015458319..684b2a02e 100644 --- a/src/domain/native/nativeInvestigationGraph.test.ts +++ b/src/domain/native/nativeInvestigationGraph.test.ts @@ -83,6 +83,61 @@ const graph = nativeInvestigationGraphSchema.parse({ }); describe("native investigation graph traces", () => { + it.each(["forward", "backward"] as const)( + "keeps both endpoints of retained edges within the %s node budget", + (direction) => { + const trace = traceNativeInvestigationGraph(graph, { + start: direction === "forward" ? "a" : "c", + direction, + limits: { max_nodes: 1 }, + }); + expect(trace.nodes).toHaveLength(1); + expect(trace.edges).toEqual([]); + expect(trace).toMatchObject({ + truncated: true, + reason: "max_nodes_reached", + reached_depth: 0, + }); + }, + ); + + it.each([{ max_nodes: 2 }, { max_edges: 1 }])( + "reports retained node depth when a sibling hits the budget %j", + (limits) => { + const branching = { + ...graph, + edges: graph.edges + .filter((edge) => edge.resolution !== "unresolved") + .map((edge) => ({ ...edge, from: "a" })), + }; + const trace = traceNativeInvestigationGraph(branching, { + start: "a", + limits, + }); + expect(trace.nodes.map(({ id }) => id)).toEqual(["a", "b"]); + expect(trace.edges.map(({ id }) => id)).toEqual(["1"]); + expect(trace.reached_depth).toBe(1); + expect(trace.truncated).toBe(true); + }, + ); + + it("retains cycle edges to admitted nodes when the node budget is full", () => { + const cycle = { + ...graph, + nodes: graph.nodes.slice(0, 2), + edges: [ + graph.edges[0], + { ...graph.edges[0], id: "back", from: "b", to: "a" }, + ], + }; + const trace = traceNativeInvestigationGraph( + nativeInvestigationGraphSchema.parse(cycle), + { start: "a", limits: { max_nodes: 2 } }, + ); + expect(trace.edges.map(({ id }) => id)).toEqual(["1", "back"]); + expect(trace).toMatchObject({ reached_depth: 1, truncated: false }); + }); + it("retains observed and inferred route edges plus unresolved dispatch", () => { const trace = traceNativeInvestigationGraph(graph, { start: "a" }); expect(trace.nodes.map(({ id }) => id)).toEqual(["a", "b", "c"]); diff --git a/src/domain/native/nativeInvestigationGraph.ts b/src/domain/native/nativeInvestigationGraph.ts index 0dd0dbe8b..9b9efc412 100644 --- a/src/domain/native/nativeInvestigationGraph.ts +++ b/src/domain/native/nativeInvestigationGraph.ts @@ -207,7 +207,6 @@ export const traceNativeInvestigationGraph = ( while (queue.length > 0) { const current = queue.shift(); if (current === undefined) break; - reachedDepth = Math.max(reachedDepth, current.depth); const adjacent = adjacency.get(current.id) ?? []; for (const edge of adjacent) { @@ -227,19 +226,24 @@ export const traceNativeInvestigationGraph = ( reason ??= "max_depth_reached"; continue; } - selectedEdges.set(edge.id, edge); - if (alreadyVisited) continue; - if (visited.size >= limits.max_nodes) { + // Check destination capacity before retaining its edge. Otherwise the node + // bound leaves a resolved edge pointing outside the returned trace. + if (!alreadyVisited && visited.size >= limits.max_nodes) { truncated = true; reason = "max_nodes_reached"; break; } + selectedEdges.set(edge.id, edge); + if (alreadyVisited) continue; if (!nodeById.has(nextId)) { truncated = true; reason ??= "edge_target_missing_from_graph"; continue; } visited.add(nextId); + // A retained node has already been reached even if a later sibling + // exhausts a budget before this node is dequeued for expansion. + reachedDepth = Math.max(reachedDepth, current.depth + 1); queue.push({ id: nextId, depth: current.depth + 1 }); } if (reason === "max_edges_reached" || reason === "max_nodes_reached") break;