From 17be88ae72c2270e45da75ce43a8c7546e3ed460 Mon Sep 17 00:00:00 2001 From: dbera Date: Thu, 6 Aug 2026 16:41:40 +0200 Subject: [PATCH 01/16] Add terminal state detection --- frontend/src/graph/graphAnalysis.test.ts | 210 +++++++++++++++++++++++ frontend/src/graph/graphAnalysis.ts | 62 +++++++ 2 files changed, 272 insertions(+) create mode 100644 frontend/src/graph/graphAnalysis.test.ts create mode 100644 frontend/src/graph/graphAnalysis.ts diff --git a/frontend/src/graph/graphAnalysis.test.ts b/frontend/src/graph/graphAnalysis.test.ts new file mode 100644 index 0000000..f4e7e9d --- /dev/null +++ b/frontend/src/graph/graphAnalysis.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it } from "vitest"; + +import { + findTerminalNodeIds, + type GraphAnalysisInput, +} from "./graphAnalysis"; + +function graph( + nodeIds: string[], + edges: GraphAnalysisInput["edges"], +): GraphAnalysisInput { + return { + nodeIds, + edges, + }; +} + +describe("findTerminalNodeIds", () => { + it("returns no terminal states for an empty graph", () => { + expect( + findTerminalNodeIds(graph([], [])), + ).toEqual([]); + }); + + it("returns an isolated state as terminal", () => { + expect( + findTerminalNodeIds(graph(["0"], [])), + ).toEqual(["0"]); + }); + + it("returns the final state of a linear graph", () => { + expect( + findTerminalNodeIds( + graph( + ["0", "1", "2"], + [ + { + id: "edge-0", + source: "0", + target: "1", + }, + { + id: "edge-1", + source: "1", + target: "2", + }, + ], + ), + ), + ).toEqual(["2"]); + }); + + it("returns multiple terminal states in node order", () => { + expect( + findTerminalNodeIds( + graph( + ["start", "left", "right"], + [ + { + id: "edge-left", + source: "start", + target: "left", + }, + { + id: "edge-right", + source: "start", + target: "right", + }, + ], + ), + ), + ).toEqual(["left", "right"]); + }); + + it("does not classify a state with a self-loop as terminal", () => { + expect( + findTerminalNodeIds( + graph( + ["loop"], + [ + { + id: "self-loop", + source: "loop", + target: "loop", + }, + ], + ), + ), + ).toEqual([]); + }); + + it("handles parallel outgoing edges", () => { + expect( + findTerminalNodeIds( + graph( + ["0", "1"], + [ + { + id: "edge-a", + source: "0", + target: "1", + }, + { + id: "edge-b", + source: "0", + target: "1", + }, + ], + ), + ), + ).toEqual(["1"]); + }); + + it("handles disconnected graph regions", () => { + expect( + findTerminalNodeIds( + graph( + ["a", "b", "c", "d", "isolated"], + [ + { + id: "edge-ab", + source: "a", + target: "b", + }, + { + id: "edge-cd", + source: "c", + target: "d", + }, + ], + ), + ), + ).toEqual(["b", "d", "isolated"]); + }); + + it("supports non-numeric node IDs", () => { + expect( + findTerminalNodeIds( + graph( + ["ready", "processing", "completed"], + [ + { + id: "start-processing", + source: "ready", + target: "processing", + }, + { + id: "finish-processing", + source: "processing", + target: "completed", + }, + ], + ), + ), + ).toEqual(["completed"]); + }); + + it("does not modify the input", () => { + const input = graph( + ["0", "1"], + [ + { + id: "edge-0", + source: "0", + target: "1", + }, + ], + ); + + const originalInput = structuredClone(input); + + findTerminalNodeIds(input); + + expect(input).toEqual(originalInput); + }); + + it("returns duplicate node IDs only once", () => { + expect( + findTerminalNodeIds( + graph( + ["0", "1", "1"], + [ + { + id: "edge-0", + source: "0", + target: "1", + }, + ], + ), + ), + ).toEqual(["1"]); + }); + + it("ignores outgoing edges from IDs absent from nodeIds", () => { + expect( + findTerminalNodeIds( + graph( + ["known"], + [ + { + id: "external-edge", + source: "external", + target: "known", + }, + ], + ), + ), + ).toEqual(["known"]); + }); +}); diff --git a/frontend/src/graph/graphAnalysis.ts b/frontend/src/graph/graphAnalysis.ts new file mode 100644 index 0000000..8c23b86 --- /dev/null +++ b/frontend/src/graph/graphAnalysis.ts @@ -0,0 +1,62 @@ +export type GraphAnalysisEdge = { + id: string; + source: string; + target: string; +}; + +export type GraphAnalysisInput = { + nodeIds: string[]; + edges: GraphAnalysisEdge[]; +}; + +export type StronglyConnectedComponent = { + id: number; + nodeIds: string[]; + internalEdgeIds: string[]; + isCyclic: boolean; +}; + +export type GraphAnalysisResult = { + terminalNodeIds: string[]; + components: StronglyConnectedComponent[]; + cyclicComponents: StronglyConnectedComponent[]; + statesInCyclicComponents: number; + largestCyclicComponentSize: number; +}; + +/** + * Returns states with no outgoing transitions. + * + * The result follows the order of input.nodeIds. Duplicate node IDs in the + * input are returned only once. A state with a self-loop is not terminal. + * + * This function intentionally performs only topology analysis. It does not + * decide whether a terminal state represents successful completion or a + * deadlock. + */ +export function findTerminalNodeIds( + input: GraphAnalysisInput, +): string[] { + const nodesWithOutgoingEdges = new Set(); + + for (const edge of input.edges) { + nodesWithOutgoingEdges.add(edge.source); + } + + const seenNodeIds = new Set(); + const terminalNodeIds: string[] = []; + + for (const nodeId of input.nodeIds) { + if (seenNodeIds.has(nodeId)) { + continue; + } + + seenNodeIds.add(nodeId); + + if (!nodesWithOutgoingEdges.has(nodeId)) { + terminalNodeIds.push(nodeId); + } + } + + return terminalNodeIds; +} From 8fe1f59e23c1b6e789f9291a5dec79e27ba9f45a Mon Sep 17 00:00:00 2001 From: dbera Date: Thu, 6 Aug 2026 16:46:17 +0200 Subject: [PATCH 02/16] Add strongly connected component analysis --- frontend/src/graph/graphAnalysis.test.ts | 458 +++++++++++++++++++++++ frontend/src/graph/graphAnalysis.ts | 275 +++++++++++++- 2 files changed, 722 insertions(+), 11 deletions(-) diff --git a/frontend/src/graph/graphAnalysis.test.ts b/frontend/src/graph/graphAnalysis.test.ts index f4e7e9d..bdf368e 100644 --- a/frontend/src/graph/graphAnalysis.test.ts +++ b/frontend/src/graph/graphAnalysis.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + findStronglyConnectedComponents, findTerminalNodeIds, type GraphAnalysisInput, } from "./graphAnalysis"; @@ -208,3 +209,460 @@ describe("findTerminalNodeIds", () => { ).toEqual(["known"]); }); }); + +describe("findStronglyConnectedComponents", () => { + it("returns no components for an empty graph", () => { + expect( + findStronglyConnectedComponents(graph([], [])), + ).toEqual([]); + }); + + it("returns an isolated state as a non-cyclic component", () => { + expect( + findStronglyConnectedComponents( + graph(["0"], []), + ), + ).toEqual([ + { + id: 0, + nodeIds: ["0"], + internalEdgeIds: [], + isCyclic: false, + }, + ]); + }); + + it("classifies a self-loop component as cyclic", () => { + expect( + findStronglyConnectedComponents( + graph( + ["0"], + [ + { + id: "self-loop", + source: "0", + target: "0", + }, + ], + ), + ), + ).toEqual([ + { + id: 0, + nodeIds: ["0"], + internalEdgeIds: ["self-loop"], + isCyclic: true, + }, + ]); + }); + + it("returns one component per state in a linear graph", () => { + expect( + findStronglyConnectedComponents( + graph( + ["0", "1", "2"], + [ + { + id: "edge-0", + source: "0", + target: "1", + }, + { + id: "edge-1", + source: "1", + target: "2", + }, + ], + ), + ), + ).toEqual([ + { + id: 0, + nodeIds: ["0"], + internalEdgeIds: [], + isCyclic: false, + }, + { + id: 1, + nodeIds: ["1"], + internalEdgeIds: [], + isCyclic: false, + }, + { + id: 2, + nodeIds: ["2"], + internalEdgeIds: [], + isCyclic: false, + }, + ]); + }); + + it("finds a simple directed cycle", () => { + expect( + findStronglyConnectedComponents( + graph( + ["0", "1", "2"], + [ + { + id: "edge-01", + source: "0", + target: "1", + }, + { + id: "edge-12", + source: "1", + target: "2", + }, + { + id: "edge-20", + source: "2", + target: "0", + }, + ], + ), + ), + ).toEqual([ + { + id: 0, + nodeIds: ["0", "1", "2"], + internalEdgeIds: [ + "edge-01", + "edge-12", + "edge-20", + ], + isCyclic: true, + }, + ]); + }); + + it("finds two disconnected cycles", () => { + expect( + findStronglyConnectedComponents( + graph( + ["a", "b", "c", "d"], + [ + { + id: "edge-ab", + source: "a", + target: "b", + }, + { + id: "edge-ba", + source: "b", + target: "a", + }, + { + id: "edge-cd", + source: "c", + target: "d", + }, + { + id: "edge-dc", + source: "d", + target: "c", + }, + ], + ), + ), + ).toEqual([ + { + id: 0, + nodeIds: ["a", "b"], + internalEdgeIds: ["edge-ab", "edge-ba"], + isCyclic: true, + }, + { + id: 1, + nodeIds: ["c", "d"], + internalEdgeIds: ["edge-cd", "edge-dc"], + isCyclic: true, + }, + ]); + }); + + it("separates a cycle from its outgoing tail", () => { + expect( + findStronglyConnectedComponents( + graph( + ["0", "1", "2"], + [ + { + id: "edge-01", + source: "0", + target: "1", + }, + { + id: "edge-10", + source: "1", + target: "0", + }, + { + id: "edge-12", + source: "1", + target: "2", + }, + ], + ), + ), + ).toEqual([ + { + id: 0, + nodeIds: ["0", "1"], + internalEdgeIds: ["edge-01", "edge-10"], + isCyclic: true, + }, + { + id: 1, + nodeIds: ["2"], + internalEdgeIds: [], + isCyclic: false, + }, + ]); + }); + + it("separates an incoming state from a cycle", () => { + expect( + findStronglyConnectedComponents( + graph( + ["entry", "a", "b"], + [ + { + id: "edge-entry-a", + source: "entry", + target: "a", + }, + { + id: "edge-ab", + source: "a", + target: "b", + }, + { + id: "edge-ba", + source: "b", + target: "a", + }, + ], + ), + ), + ).toEqual([ + { + id: 0, + nodeIds: ["entry"], + internalEdgeIds: [], + isCyclic: false, + }, + { + id: 1, + nodeIds: ["a", "b"], + internalEdgeIds: ["edge-ab", "edge-ba"], + isCyclic: true, + }, + ]); + }); + + it("preserves parallel internal edge IDs", () => { + expect( + findStronglyConnectedComponents( + graph( + ["0", "1"], + [ + { + id: "edge-forward-a", + source: "0", + target: "1", + }, + { + id: "edge-forward-b", + source: "0", + target: "1", + }, + { + id: "edge-back", + source: "1", + target: "0", + }, + ], + ), + ), + ).toEqual([ + { + id: 0, + nodeIds: ["0", "1"], + internalEdgeIds: [ + "edge-forward-a", + "edge-forward-b", + "edge-back", + ], + isCyclic: true, + }, + ]); + }); + + it("handles mixed cyclic and acyclic regions", () => { + const components = findStronglyConnectedComponents( + graph( + ["start", "a", "b", "end", "isolated"], + [ + { + id: "edge-start-a", + source: "start", + target: "a", + }, + { + id: "edge-ab", + source: "a", + target: "b", + }, + { + id: "edge-ba", + source: "b", + target: "a", + }, + { + id: "edge-b-end", + source: "b", + target: "end", + }, + ], + ), + ); + + expect(components).toEqual([ + { + id: 0, + nodeIds: ["start"], + internalEdgeIds: [], + isCyclic: false, + }, + { + id: 1, + nodeIds: ["a", "b"], + internalEdgeIds: ["edge-ab", "edge-ba"], + isCyclic: true, + }, + { + id: 2, + nodeIds: ["end"], + internalEdgeIds: [], + isCyclic: false, + }, + { + id: 3, + nodeIds: ["isolated"], + internalEdgeIds: [], + isCyclic: false, + }, + ]); + }); + + it("ignores edges that reference unknown nodes", () => { + expect( + findStronglyConnectedComponents( + graph( + ["known"], + [ + { + id: "unknown-source", + source: "external", + target: "known", + }, + { + id: "unknown-target", + source: "known", + target: "external", + }, + ], + ), + ), + ).toEqual([ + { + id: 0, + nodeIds: ["known"], + internalEdgeIds: [], + isCyclic: false, + }, + ]); + }); + + it("does not modify the input", () => { + const input = graph( + ["0", "1"], + [ + { + id: "edge-01", + source: "0", + target: "1", + }, + { + id: "edge-10", + source: "1", + target: "0", + }, + ], + ); + + const originalInput = structuredClone(input); + + findStronglyConnectedComponents(input); + + expect(input).toEqual(originalInput); + }); + + it("handles a long graph without recursive calls", () => { + const nodeCount = 20_000; + const nodeIds = Array.from( + { length: nodeCount }, + (_, index) => String(index), + ); + + const edges = Array.from( + { length: nodeCount - 1 }, + (_, index) => ({ + id: `edge-${index}`, + source: String(index), + target: String(index + 1), + }), + ); + + const components = findStronglyConnectedComponents( + graph(nodeIds, edges), + ); + + expect(components).toHaveLength(nodeCount); + expect(components[0].nodeIds).toEqual(["0"]); + expect(components[nodeCount - 1].nodeIds).toEqual([ + String(nodeCount - 1), + ]); + expect( + components.every((component) => !component.isCyclic), + ).toBe(true); + }); + + it("handles one large strongly connected component", () => { + const nodeCount = 10_000; + const nodeIds = Array.from( + { length: nodeCount }, + (_, index) => String(index), + ); + + const edges = Array.from( + { length: nodeCount }, + (_, index) => ({ + id: `edge-${index}`, + source: String(index), + target: String((index + 1) % nodeCount), + }), + ); + + const components = findStronglyConnectedComponents( + graph(nodeIds, edges), + ); + + expect(components).toHaveLength(1); + expect(components[0].nodeIds).toHaveLength(nodeCount); + expect(components[0].internalEdgeIds).toHaveLength( + nodeCount, + ); + expect(components[0].isCyclic).toBe(true); + }); +}); diff --git a/frontend/src/graph/graphAnalysis.ts b/frontend/src/graph/graphAnalysis.ts index 8c23b86..b141700 100644 --- a/frontend/src/graph/graphAnalysis.ts +++ b/frontend/src/graph/graphAnalysis.ts @@ -24,6 +24,74 @@ export type GraphAnalysisResult = { largestCyclicComponentSize: number; }; +type GraphTopology = { + nodeIds: string[]; + nodeOrder: Map; + outgoing: Map; + incoming: Map; + validEdges: GraphAnalysisEdge[]; + selfLoopNodeIds: Set; +}; + +/** + * Builds a normalized topology containing only unique nodes listed in + * input.nodeIds and edges whose source and target are both known nodes. + * + * Normal graph validation should reject unknown references before analysis. + * Ignoring unknown references here keeps the pure analysis functions robust + * when used independently. + */ +function buildTopology(input: GraphAnalysisInput): GraphTopology { + const nodeIds: string[] = []; + const nodeOrder = new Map(); + + for (const nodeId of input.nodeIds) { + if (nodeOrder.has(nodeId)) { + continue; + } + + nodeOrder.set(nodeId, nodeIds.length); + nodeIds.push(nodeId); + } + + const outgoing = new Map(); + const incoming = new Map(); + + for (const nodeId of nodeIds) { + outgoing.set(nodeId, []); + incoming.set(nodeId, []); + } + + const validEdges: GraphAnalysisEdge[] = []; + const selfLoopNodeIds = new Set(); + + for (const edge of input.edges) { + if ( + !nodeOrder.has(edge.source) || + !nodeOrder.has(edge.target) + ) { + continue; + } + + outgoing.get(edge.source)?.push(edge.target); + incoming.get(edge.target)?.push(edge.source); + validEdges.push(edge); + + if (edge.source === edge.target) { + selfLoopNodeIds.add(edge.source); + } + } + + return { + nodeIds, + nodeOrder, + outgoing, + incoming, + validEdges, + selfLoopNodeIds, + }; +} + /** * Returns states with no outgoing transitions. * @@ -37,26 +105,211 @@ export type GraphAnalysisResult = { export function findTerminalNodeIds( input: GraphAnalysisInput, ): string[] { - const nodesWithOutgoingEdges = new Set(); + const topology = buildTopology(input); - for (const edge of input.edges) { - nodesWithOutgoingEdges.add(edge.source); + return topology.nodeIds.filter( + (nodeId) => topology.outgoing.get(nodeId)?.length === 0, + ); +} + +/** + * Computes the finishing order of an iterative depth-first traversal. + * + * An explicit stack is used instead of recursive calls so long paths do not + * risk exceeding the JavaScript call-stack limit. + */ +function computeFinishingOrder( + nodeIds: string[], + adjacency: Map, +): string[] { + const visited = new Set(); + const finishingOrder: string[] = []; + + for (const startNodeId of nodeIds) { + if (visited.has(startNodeId)) { + continue; + } + + visited.add(startNodeId); + + const stack: Array<{ + nodeId: string; + nextNeighborIndex: number; + }> = [ + { + nodeId: startNodeId, + nextNeighborIndex: 0, + }, + ]; + + while (stack.length > 0) { + const frame = stack[stack.length - 1]; + const neighbors = adjacency.get(frame.nodeId) ?? []; + + if (frame.nextNeighborIndex < neighbors.length) { + const neighborId = neighbors[frame.nextNeighborIndex]; + frame.nextNeighborIndex += 1; + + if (!visited.has(neighborId)) { + visited.add(neighborId); + stack.push({ + nodeId: neighborId, + nextNeighborIndex: 0, + }); + } + + continue; + } + + finishingOrder.push(frame.nodeId); + stack.pop(); + } } - const seenNodeIds = new Set(); - const terminalNodeIds: string[] = []; + return finishingOrder; +} - for (const nodeId of input.nodeIds) { - if (seenNodeIds.has(nodeId)) { +/** + * Collects one component using an iterative traversal. + */ +function collectComponent( + startNodeId: string, + adjacency: Map, + assignedNodeIds: Set, +): string[] { + const componentNodeIds: string[] = []; + const stack = [startNodeId]; + + assignedNodeIds.add(startNodeId); + + while (stack.length > 0) { + const nodeId = stack.pop(); + + if (nodeId === undefined) { + continue; + } + + componentNodeIds.push(nodeId); + + const neighbors = adjacency.get(nodeId) ?? []; + + // Reverse iteration preserves the original adjacency order when using + // a last-in-first-out traversal stack. + for ( + let neighborIndex = neighbors.length - 1; + neighborIndex >= 0; + neighborIndex -= 1 + ) { + const neighborId = neighbors[neighborIndex]; + + if (!assignedNodeIds.has(neighborId)) { + assignedNodeIds.add(neighborId); + stack.push(neighborId); + } + } + } + + return componentNodeIds; +} + +/** + * Finds all strongly connected components in a directed graph. + * + * The implementation uses an iterative two-pass depth-first traversal. + * Explicit stacks avoid recursion-depth failures on long graphs. + * + * Components are returned in deterministic graph-node order. A component is + * cyclic when it contains multiple states or when its single state has a + * self-loop. + */ +export function findStronglyConnectedComponents( + input: GraphAnalysisInput, +): StronglyConnectedComponent[] { + const topology = buildTopology(input); + + if (topology.nodeIds.length === 0) { + return []; + } + + const finishingOrder = computeFinishingOrder( + topology.nodeIds, + topology.outgoing, + ); + + const assignedNodeIds = new Set(); + const componentNodeIdGroups: string[][] = []; + + for ( + let orderIndex = finishingOrder.length - 1; + orderIndex >= 0; + orderIndex -= 1 + ) { + const startNodeId = finishingOrder[orderIndex]; + + if (assignedNodeIds.has(startNodeId)) { continue; } - seenNodeIds.add(nodeId); + const componentNodeIds = collectComponent( + startNodeId, + topology.incoming, + assignedNodeIds, + ); + + componentNodeIds.sort( + (leftNodeId, rightNodeId) => + (topology.nodeOrder.get(leftNodeId) ?? 0) - + (topology.nodeOrder.get(rightNodeId) ?? 0), + ); + + componentNodeIdGroups.push(componentNodeIds); + } + + componentNodeIdGroups.sort((left, right) => { + const leftOrder = topology.nodeOrder.get(left[0]) ?? 0; + const rightOrder = topology.nodeOrder.get(right[0]) ?? 0; + + return leftOrder - rightOrder; + }); + + const componentIndexByNodeId = new Map(); + + componentNodeIdGroups.forEach( + (componentNodeIds, componentIndex) => { + for (const nodeId of componentNodeIds) { + componentIndexByNodeId.set(nodeId, componentIndex); + } + }, + ); + + const internalEdgeIdsByComponent = + componentNodeIdGroups.map(() => [] as string[]); + + for (const edge of topology.validEdges) { + const sourceComponentIndex = + componentIndexByNodeId.get(edge.source); + const targetComponentIndex = + componentIndexByNodeId.get(edge.target); - if (!nodesWithOutgoingEdges.has(nodeId)) { - terminalNodeIds.push(nodeId); + if ( + sourceComponentIndex !== undefined && + sourceComponentIndex === targetComponentIndex + ) { + internalEdgeIdsByComponent[sourceComponentIndex].push( + edge.id, + ); } } - return terminalNodeIds; + return componentNodeIdGroups.map( + (componentNodeIds, componentIndex) => ({ + id: componentIndex, + nodeIds: componentNodeIds, + internalEdgeIds: + internalEdgeIdsByComponent[componentIndex], + isCyclic: + componentNodeIds.length > 1 || + topology.selfLoopNodeIds.has(componentNodeIds[0]), + }), + ); } From 35b6177d953a5d8c05c1bd7181bc4059a76dd2c1 Mon Sep 17 00:00:00 2001 From: dbera Date: Thu, 6 Aug 2026 16:53:40 +0200 Subject: [PATCH 03/16] Combine graph topology analyses --- frontend/src/graph/graphAnalysis.test.ts | 236 +++++++++++++++++++++++ frontend/src/graph/graphAnalysis.ts | 98 +++++++--- 2 files changed, 310 insertions(+), 24 deletions(-) diff --git a/frontend/src/graph/graphAnalysis.test.ts b/frontend/src/graph/graphAnalysis.test.ts index bdf368e..6937004 100644 --- a/frontend/src/graph/graphAnalysis.test.ts +++ b/frontend/src/graph/graphAnalysis.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + analyzeGraph, findStronglyConnectedComponents, findTerminalNodeIds, type GraphAnalysisInput, @@ -666,3 +667,238 @@ describe("findStronglyConnectedComponents", () => { expect(components[0].isCyclic).toBe(true); }); }); + +describe("analyzeGraph", () => { + it("returns an empty result for an empty graph", () => { + expect(analyzeGraph(graph([], []))).toEqual({ + terminalNodeIds: [], + components: [], + cyclicComponents: [], + statesInCyclicComponents: 0, + largestCyclicComponentSize: 0, + }); + }); + + it("combines terminal and component analysis", () => { + const result = analyzeGraph( + graph( + ["start", "a", "b", "end", "isolated"], + [ + { + id: "edge-start-a", + source: "start", + target: "a", + }, + { + id: "edge-ab", + source: "a", + target: "b", + }, + { + id: "edge-ba", + source: "b", + target: "a", + }, + { + id: "edge-b-end", + source: "b", + target: "end", + }, + ], + ), + ); + + expect(result.terminalNodeIds).toEqual([ + "end", + "isolated", + ]); + expect(result.components).toEqual([ + { + id: 0, + nodeIds: ["start"], + internalEdgeIds: [], + isCyclic: false, + }, + { + id: 1, + nodeIds: ["a", "b"], + internalEdgeIds: ["edge-ab", "edge-ba"], + isCyclic: true, + }, + { + id: 2, + nodeIds: ["end"], + internalEdgeIds: [], + isCyclic: false, + }, + { + id: 3, + nodeIds: ["isolated"], + internalEdgeIds: [], + isCyclic: false, + }, + ]); + expect(result.cyclicComponents).toEqual([ + { + id: 1, + nodeIds: ["a", "b"], + internalEdgeIds: ["edge-ab", "edge-ba"], + isCyclic: true, + }, + ]); + expect(result.statesInCyclicComponents).toBe(2); + expect(result.largestCyclicComponentSize).toBe(2); + }); + + it("counts a self-loop as a cyclic component", () => { + const result = analyzeGraph( + graph( + ["loop", "terminal"], + [ + { + id: "self-loop", + source: "loop", + target: "loop", + }, + ], + ), + ); + + expect(result.terminalNodeIds).toEqual(["terminal"]); + expect(result.cyclicComponents).toHaveLength(1); + expect(result.cyclicComponents[0].nodeIds).toEqual([ + "loop", + ]); + expect(result.statesInCyclicComponents).toBe(1); + expect(result.largestCyclicComponentSize).toBe(1); + }); + + it("orders cyclic components by descending size", () => { + const result = analyzeGraph( + graph( + ["a", "b", "x", "y", "z"], + [ + { + id: "edge-ab", + source: "a", + target: "b", + }, + { + id: "edge-ba", + source: "b", + target: "a", + }, + { + id: "edge-xy", + source: "x", + target: "y", + }, + { + id: "edge-yz", + source: "y", + target: "z", + }, + { + id: "edge-zx", + source: "z", + target: "x", + }, + ], + ), + ); + + expect( + result.cyclicComponents.map( + (component) => component.nodeIds, + ), + ).toEqual([ + ["x", "y", "z"], + ["a", "b"], + ]); + expect(result.statesInCyclicComponents).toBe(5); + expect(result.largestCyclicComponentSize).toBe(3); + }); + + it("preserves component order when cyclic sizes are equal", () => { + const result = analyzeGraph( + graph( + ["a", "b", "c", "d"], + [ + { + id: "edge-ab", + source: "a", + target: "b", + }, + { + id: "edge-ba", + source: "b", + target: "a", + }, + { + id: "edge-cd", + source: "c", + target: "d", + }, + { + id: "edge-dc", + source: "d", + target: "c", + }, + ], + ), + ); + + expect( + result.cyclicComponents.map( + (component) => component.nodeIds, + ), + ).toEqual([ + ["a", "b"], + ["c", "d"], + ]); + }); + + it("does not modify the input", () => { + const input = graph( + ["0", "1"], + [ + { + id: "edge-01", + source: "0", + target: "1", + }, + ], + ); + const originalInput = structuredClone(input); + + analyzeGraph(input); + + expect(input).toEqual(originalInput); + }); + + it("returns zero cyclic statistics for an acyclic graph", () => { + const result = analyzeGraph( + graph( + ["0", "1", "2"], + [ + { + id: "edge-01", + source: "0", + target: "1", + }, + { + id: "edge-12", + source: "1", + target: "2", + }, + ], + ), + ); + + expect(result.terminalNodeIds).toEqual(["2"]); + expect(result.components).toHaveLength(3); + expect(result.cyclicComponents).toEqual([]); + expect(result.statesInCyclicComponents).toBe(0); + expect(result.largestCyclicComponentSize).toBe(0); + }); +}); diff --git a/frontend/src/graph/graphAnalysis.ts b/frontend/src/graph/graphAnalysis.ts index b141700..8a5ddf9 100644 --- a/frontend/src/graph/graphAnalysis.ts +++ b/frontend/src/graph/graphAnalysis.ts @@ -92,6 +92,14 @@ function buildTopology(input: GraphAnalysisInput): GraphTopology { }; } +function findTerminalNodeIdsFromTopology( + topology: GraphTopology, +): string[] { + return topology.nodeIds.filter( + (nodeId) => topology.outgoing.get(nodeId)?.length === 0, + ); +} + /** * Returns states with no outgoing transitions. * @@ -105,11 +113,7 @@ function buildTopology(input: GraphAnalysisInput): GraphTopology { export function findTerminalNodeIds( input: GraphAnalysisInput, ): string[] { - const topology = buildTopology(input); - - return topology.nodeIds.filter( - (nodeId) => topology.outgoing.get(nodeId)?.length === 0, - ); + return findTerminalNodeIdsFromTopology(buildTopology(input)); } /** @@ -169,9 +173,7 @@ function computeFinishingOrder( return finishingOrder; } -/** - * Collects one component using an iterative traversal. - */ +/** Collects one component using an iterative traversal. */ function collectComponent( startNodeId: string, adjacency: Map, @@ -193,8 +195,7 @@ function collectComponent( const neighbors = adjacency.get(nodeId) ?? []; - // Reverse iteration preserves the original adjacency order when using - // a last-in-first-out traversal stack. + // Reverse iteration preserves adjacency order with a LIFO stack. for ( let neighborIndex = neighbors.length - 1; neighborIndex >= 0; @@ -212,21 +213,9 @@ function collectComponent( return componentNodeIds; } -/** - * Finds all strongly connected components in a directed graph. - * - * The implementation uses an iterative two-pass depth-first traversal. - * Explicit stacks avoid recursion-depth failures on long graphs. - * - * Components are returned in deterministic graph-node order. A component is - * cyclic when it contains multiple states or when its single state has a - * self-loop. - */ -export function findStronglyConnectedComponents( - input: GraphAnalysisInput, +function findStronglyConnectedComponentsFromTopology( + topology: GraphTopology, ): StronglyConnectedComponent[] { - const topology = buildTopology(input); - if (topology.nodeIds.length === 0) { return []; } @@ -313,3 +302,64 @@ export function findStronglyConnectedComponents( }), ); } + +/** + * Finds all strongly connected components in a directed graph. + * + * The implementation uses an iterative two-pass depth-first traversal. + * Explicit stacks avoid recursion-depth failures on long graphs. + * Components are returned in deterministic graph-node order. + */ +export function findStronglyConnectedComponents( + input: GraphAnalysisInput, +): StronglyConnectedComponent[] { + return findStronglyConnectedComponentsFromTopology( + buildTopology(input), + ); +} + +/** + * Computes the complete topology analysis while constructing the normalized + * graph topology only once. + * + * Cyclic components are ordered by descending state count. Components with + * the same size retain their deterministic component order. + */ +export function analyzeGraph( + input: GraphAnalysisInput, +): GraphAnalysisResult { + const topology = buildTopology(input); + const terminalNodeIds = + findTerminalNodeIdsFromTopology(topology); + const components = + findStronglyConnectedComponentsFromTopology(topology); + + const cyclicComponents = components + .filter((component) => component.isCyclic) + .sort((left, right) => { + const sizeDifference = + right.nodeIds.length - left.nodeIds.length; + + if (sizeDifference !== 0) { + return sizeDifference; + } + + return left.id - right.id; + }); + + const statesInCyclicComponents = cyclicComponents.reduce( + (total, component) => total + component.nodeIds.length, + 0, + ); + + const largestCyclicComponentSize = + cyclicComponents[0]?.nodeIds.length ?? 0; + + return { + terminalNodeIds, + components, + cyclicComponents, + statesInCyclicComponents, + largestCyclicComponentSize, + }; +} From 23dc91b29366133d4352540829cce2bb0c21fd5b Mon Sep 17 00:00:00 2001 From: dbera Date: Thu, 6 Aug 2026 17:00:22 +0200 Subject: [PATCH 04/16] Add graph analysis worker protocol --- frontend/src/graph/graphAnalysisWorker.ts | 79 ++++++++++++++++++++ frontend/src/workers/graphAnalysis.worker.ts | 69 +++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 frontend/src/graph/graphAnalysisWorker.ts create mode 100644 frontend/src/workers/graphAnalysis.worker.ts diff --git a/frontend/src/graph/graphAnalysisWorker.ts b/frontend/src/graph/graphAnalysisWorker.ts new file mode 100644 index 0000000..dc262ab --- /dev/null +++ b/frontend/src/graph/graphAnalysisWorker.ts @@ -0,0 +1,79 @@ +import type { + GraphAnalysisInput, + GraphAnalysisResult, +} from "./graphAnalysis"; + +export type GraphAnalysisWorkerRequest = { + type: "analyze"; + requestId: string; + input: GraphAnalysisInput; +}; + +export type GraphAnalysisWorkerSuccessResponse = { + type: "success"; + requestId: string; + result: GraphAnalysisResult; +}; + +export type GraphAnalysisWorkerErrorResponse = { + type: "error"; + requestId: string; + error: string; +}; + +export type GraphAnalysisWorkerResponse = + | GraphAnalysisWorkerSuccessResponse + | GraphAnalysisWorkerErrorResponse; + +export function createGraphAnalysisWorkerRequest( + requestId: string, + input: GraphAnalysisInput, +): GraphAnalysisWorkerRequest { + return { + type: "analyze", + requestId, + input, + }; +} + +export function isGraphAnalysisWorkerResponse( + value: unknown, +): value is GraphAnalysisWorkerResponse { + if (typeof value !== "object" || value === null) { + return false; + } + + const candidate = value as Record; + + if (typeof candidate.requestId !== "string") { + return false; + } + + if (candidate.type === "success") { + return isGraphAnalysisResult(candidate.result); + } + + if (candidate.type === "error") { + return typeof candidate.error === "string"; + } + + return false; +} + +function isGraphAnalysisResult( + value: unknown, +): value is GraphAnalysisResult { + if (typeof value !== "object" || value === null) { + return false; + } + + const candidate = value as Record; + + return ( + Array.isArray(candidate.terminalNodeIds) && + Array.isArray(candidate.components) && + Array.isArray(candidate.cyclicComponents) && + typeof candidate.statesInCyclicComponents === "number" && + typeof candidate.largestCyclicComponentSize === "number" + ); +} diff --git a/frontend/src/workers/graphAnalysis.worker.ts b/frontend/src/workers/graphAnalysis.worker.ts new file mode 100644 index 0000000..a00fc65 --- /dev/null +++ b/frontend/src/workers/graphAnalysis.worker.ts @@ -0,0 +1,69 @@ +import { analyzeGraph } from "../graph/graphAnalysis"; +import type { + GraphAnalysisWorkerErrorResponse, + GraphAnalysisWorkerRequest, + GraphAnalysisWorkerResponse, + GraphAnalysisWorkerSuccessResponse, +} from "../graph/graphAnalysisWorker"; + +type GraphAnalysisWorkerScope = { + addEventListener: ( + type: "message", + listener: ( + event: MessageEvent, + ) => void, + ) => void; + + postMessage: ( + response: GraphAnalysisWorkerResponse, + ) => void; +}; + +const workerScope = + globalThis as unknown as GraphAnalysisWorkerScope; + +workerScope.addEventListener( + "message", + ( + event: MessageEvent, + ): void => { + const request = event.data; + + if (request.type !== "analyze") { + return; + } + + try { + const response: + GraphAnalysisWorkerSuccessResponse = { + type: "success", + requestId: request.requestId, + result: analyzeGraph(request.input), + }; + + workerScope.postMessage(response); + } catch (error: unknown) { + const response: + GraphAnalysisWorkerErrorResponse = { + type: "error", + requestId: request.requestId, + error: getErrorMessage(error), + }; + + workerScope.postMessage(response); + } + }, +); + +function getErrorMessage(error: unknown): string { + if ( + error instanceof Error && + error.message.length > 0 + ) { + return error.message; + } + + return "Graph analysis failed for an unknown reason."; +} + +export {}; From d288dbbecd38b444ece60cdb73c94642c0b24335 Mon Sep 17 00:00:00 2001 From: dbera Date: Thu, 6 Aug 2026 17:06:48 +0200 Subject: [PATCH 05/16] Add graph analysis worker controller --- .../src/graph/graphAnalysisController.test.ts | 260 ++++++++++++++++++ frontend/src/graph/graphAnalysisController.ts | 202 ++++++++++++++ 2 files changed, 462 insertions(+) create mode 100644 frontend/src/graph/graphAnalysisController.test.ts create mode 100644 frontend/src/graph/graphAnalysisController.ts diff --git a/frontend/src/graph/graphAnalysisController.test.ts b/frontend/src/graph/graphAnalysisController.test.ts new file mode 100644 index 0000000..86772e1 --- /dev/null +++ b/frontend/src/graph/graphAnalysisController.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, it } from "vitest"; + +import type { GraphAnalysisResult } from "./graphAnalysis"; +import { + GraphAnalysisController, + type GraphAnalysisState, + type GraphAnalysisWorkerLike, +} from "./graphAnalysisController"; + +class FakeWorker implements GraphAnalysisWorkerLike { + public postedMessages: unknown[] = []; + public terminateCount = 0; + public onmessage: ((event: MessageEvent) => void) | null = null; + public onerror: ((event: ErrorEvent) => void) | null = null; + + public postMessage(message: unknown): void { + this.postedMessages.push(message); + } + + public terminate(): void { + this.terminateCount += 1; + } + + public respond(value: unknown): void { + this.onmessage?.({ data: value } as MessageEvent); + } + + public fail(message: string): void { + this.onerror?.({ message } as ErrorEvent); + } +} + +const INPUT = { + nodeIds: ["0", "1"], + edges: [ + { + id: "edge-01", + source: "0", + target: "1", + }, + ], +}; + +const RESULT: GraphAnalysisResult = { + terminalNodeIds: ["1"], + components: [ + { + id: 0, + nodeIds: ["0"], + internalEdgeIds: [], + isCyclic: false, + }, + { + id: 1, + nodeIds: ["1"], + internalEdgeIds: [], + isCyclic: false, + }, + ], + cyclicComponents: [], + statesInCyclicComponents: 0, + largestCyclicComponentSize: 0, +}; + +function setup() { + const workers: FakeWorker[] = []; + const states: GraphAnalysisState[] = []; + const controller = new GraphAnalysisController( + () => { + const worker = new FakeWorker(); + workers.push(worker); + return worker; + }, + (state) => states.push(state), + ); + + return { controller, workers, states }; +} + +function getRequestId(worker: FakeWorker): string { + const request = worker.postedMessages[0] as { + requestId: string; + }; + return request.requestId; +} + +describe("GraphAnalysisController", () => { + it("starts analysis and posts a typed request", () => { + const { controller, workers, states } = setup(); + + controller.run(INPUT); + + expect(workers).toHaveLength(1); + expect(states).toEqual([ + { + status: "running", + result: null, + error: null, + }, + ]); + expect(workers[0].postedMessages).toEqual([ + { + type: "analyze", + requestId: "graph-analysis-1", + input: INPUT, + }, + ]); + }); + + it("stores a successful result and terminates the worker", () => { + const { controller, workers } = setup(); + + controller.run(INPUT); + const requestId = getRequestId(workers[0]); + workers[0].respond({ + type: "success", + requestId, + result: RESULT, + }); + + expect(controller.getState()).toEqual({ + status: "completed", + result: RESULT, + error: null, + }); + expect(workers[0].terminateCount).toBe(1); + }); + + it("stores a worker error response", () => { + const { controller, workers } = setup(); + + controller.run(INPUT); + const requestId = getRequestId(workers[0]); + workers[0].respond({ + type: "error", + requestId, + error: "Analysis failed.", + }); + + expect(controller.getState()).toEqual({ + status: "failed", + result: null, + error: "Analysis failed.", + }); + }); + + it("stores a worker runtime error", () => { + const { controller, workers } = setup(); + + controller.run(INPUT); + workers[0].fail("Worker crashed."); + + expect(controller.getState()).toEqual({ + status: "failed", + result: null, + error: "Worker crashed.", + }); + }); + + it("cancels a running analysis", () => { + const { controller, workers } = setup(); + + controller.run(INPUT); + controller.cancel(); + + expect(controller.getState()).toEqual({ + status: "cancelled", + result: null, + error: null, + }); + expect(workers[0].terminateCount).toBe(1); + }); + + it("ignores cancellation when analysis is not running", () => { + const { controller, workers, states } = setup(); + + controller.cancel(); + + expect(workers).toEqual([]); + expect(states).toEqual([]); + expect(controller.getState().status).toBe("not-run"); + }); + + it("terminates the previous worker when analysis is run again", () => { + const { controller, workers } = setup(); + + controller.run(INPUT); + controller.run(INPUT); + + expect(workers).toHaveLength(2); + expect(workers[0].terminateCount).toBe(1); + expect(workers[1].postedMessages[0]).toMatchObject({ + requestId: "graph-analysis-2", + }); + }); + + it("ignores a stale response from an earlier request", () => { + const { controller, workers } = setup(); + + controller.run(INPUT); + const firstWorker = workers[0]; + const firstRequestId = getRequestId(firstWorker); + + controller.run(INPUT); + const secondWorker = workers[1]; + const secondRequestId = getRequestId(secondWorker); + + firstWorker.respond({ + type: "success", + requestId: firstRequestId, + result: RESULT, + }); + + expect(controller.getState().status).toBe("running"); + + secondWorker.respond({ + type: "success", + requestId: secondRequestId, + result: RESULT, + }); + + expect(controller.getState().status).toBe("completed"); + }); + + it("ignores malformed worker responses", () => { + const { controller, workers } = setup(); + + controller.run(INPUT); + workers[0].respond({ type: "success" }); + + expect(controller.getState().status).toBe("running"); + expect(workers[0].terminateCount).toBe(0); + }); + + it("resets state and terminates active work", () => { + const { controller, workers } = setup(); + + controller.run(INPUT); + controller.reset(); + + expect(controller.getState()).toEqual({ + status: "not-run", + result: null, + error: null, + }); + expect(workers[0].terminateCount).toBe(1); + }); + + it("disposes active work and ignores future runs", () => { + const { controller, workers, states } = setup(); + + controller.run(INPUT); + controller.dispose(); + controller.run(INPUT); + + expect(workers).toHaveLength(1); + expect(workers[0].terminateCount).toBe(1); + expect(states).toHaveLength(1); + }); +}); diff --git a/frontend/src/graph/graphAnalysisController.ts b/frontend/src/graph/graphAnalysisController.ts new file mode 100644 index 0000000..a80aaad --- /dev/null +++ b/frontend/src/graph/graphAnalysisController.ts @@ -0,0 +1,202 @@ +import type { GraphAnalysisInput, GraphAnalysisResult } from "./graphAnalysis"; +import { + createGraphAnalysisWorkerRequest, + isGraphAnalysisWorkerResponse, + type GraphAnalysisWorkerResponse, +} from "./graphAnalysisWorker"; + +export type GraphAnalysisStatus = + | "not-run" + | "running" + | "completed" + | "failed" + | "cancelled"; + +export type GraphAnalysisState = { + status: GraphAnalysisStatus; + result: GraphAnalysisResult | null; + error: string | null; +}; + +export type GraphAnalysisWorkerLike = { + postMessage: (message: unknown) => void; + terminate: () => void; + onmessage: ((event: MessageEvent) => void) | null; + onerror: ((event: ErrorEvent) => void) | null; +}; + +export type GraphAnalysisWorkerFactory = + () => GraphAnalysisWorkerLike; + +export type GraphAnalysisStateListener = ( + state: GraphAnalysisState, +) => void; + +const INITIAL_STATE: GraphAnalysisState = { + status: "not-run", + result: null, + error: null, +}; + +export class GraphAnalysisController { + private state: GraphAnalysisState = INITIAL_STATE; + private worker: GraphAnalysisWorkerLike | null = null; + private activeRequestId: string | null = null; + private nextRequestNumber = 1; + private disposed = false; + + private readonly createWorker: GraphAnalysisWorkerFactory; + private readonly onStateChange: GraphAnalysisStateListener; + + public constructor( + createWorker: GraphAnalysisWorkerFactory, + onStateChange: GraphAnalysisStateListener, + ) { + this.createWorker = createWorker; + this.onStateChange = onStateChange; + } + + public getState(): GraphAnalysisState { + return this.state; + } + + public run(input: GraphAnalysisInput): void { + if (this.disposed) { + return; + } + + this.terminateWorker(); + + const requestId = `graph-analysis-${this.nextRequestNumber}`; + this.nextRequestNumber += 1; + + const worker = this.createWorker(); + this.worker = worker; + this.activeRequestId = requestId; + + worker.onmessage = (event: MessageEvent) => { + this.handleWorkerMessage(requestId, event.data); + }; + + worker.onerror = (event: ErrorEvent) => { + this.handleWorkerError( + requestId, + event.message || "Graph analysis worker failed.", + ); + }; + + this.setState({ + status: "running", + result: null, + error: null, + }); + + worker.postMessage( + createGraphAnalysisWorkerRequest(requestId, input), + ); + } + + public cancel(): void { + if (this.disposed || this.state.status !== "running") { + return; + } + + this.terminateWorker(); + this.setState({ + status: "cancelled", + result: null, + error: null, + }); + } + + public reset(): void { + if (this.disposed) { + return; + } + + this.terminateWorker(); + this.setState(INITIAL_STATE); + } + + public dispose(): void { + if (this.disposed) { + return; + } + + this.terminateWorker(); + this.disposed = true; + } + + private handleWorkerMessage( + expectedRequestId: string, + value: unknown, + ): void { + if ( + this.disposed || + expectedRequestId !== this.activeRequestId || + !isGraphAnalysisWorkerResponse(value) || + value.requestId !== this.activeRequestId + ) { + return; + } + + this.terminateWorker(); + + if (value.type === "success") { + this.setState({ + status: "completed", + result: value.result, + error: null, + }); + return; + } + + this.setState({ + status: "failed", + result: null, + error: value.error, + }); + } + + private handleWorkerError( + expectedRequestId: string, + error: string, + ): void { + if ( + this.disposed || + expectedRequestId !== this.activeRequestId + ) { + return; + } + + this.terminateWorker(); + this.setState({ + status: "failed", + result: null, + error, + }); + } + + private terminateWorker(): void { + if (this.worker !== null) { + this.worker.onmessage = null; + this.worker.onerror = null; + this.worker.terminate(); + } + + this.worker = null; + this.activeRequestId = null; + } + + private setState(state: GraphAnalysisState): void { + this.state = state; + this.onStateChange(state); + } +} + +export function isWorkerResponseForRequest( + response: GraphAnalysisWorkerResponse, + requestId: string, +): boolean { + return response.requestId === requestId; +} From 3366c663d94944175fba28e2f203fffe57904850 Mon Sep 17 00:00:00 2001 From: dbera Date: Thu, 6 Aug 2026 17:19:48 +0200 Subject: [PATCH 06/16] Add on-demand graph analysis panel --- frontend/src/App.css | 132 ++++++++++++++ frontend/src/App.tsx | 237 ++++++++++++++++++++----- frontend/src/graph/useGraphAnalysis.ts | 67 +++++++ 3 files changed, 395 insertions(+), 41 deletions(-) create mode 100644 frontend/src/graph/useGraphAnalysis.ts diff --git a/frontend/src/App.css b/frontend/src/App.css index 3c9ce18..1e381c6 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -347,3 +347,135 @@ button.active { flex-wrap: wrap; } } + +.side-panel-tabs { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; + margin: -4px 0 18px; + padding: 4px; + border-radius: 9px; + background: #e2e8f0; +} + +.side-panel-tabs button { + border-color: transparent; + background: transparent; + font-size: 13px; +} + +.side-panel-tabs button.active { + border-color: #bfdbfe; + background: #ffffff; + color: #1d4ed8; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08); +} + +.analysis-heading { + margin-bottom: 16px; +} + +.analysis-heading h2 { + margin: 4px 0 0; + font-size: 18px; + line-height: 1.3; +} + +.analysis-message, +.analysis-results { + color: #475569; +} + +.analysis-message > p:first-child, +.analysis-results > p:first-child { + margin-top: 0; +} + +.analysis-input-summary, +.analysis-summary { + display: grid; + gap: 8px; + margin: 16px 0; +} + +.analysis-input-summary > div, +.analysis-summary > div { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 16px; + padding: 9px 11px; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #f8fafc; +} + +.analysis-input-summary dt, +.analysis-summary dt { + font-size: 13px; + font-weight: 650; +} + +.analysis-input-summary dd, +.analysis-summary dd { + margin: 0; + color: #0f172a; + font-variant-numeric: tabular-nums; + font-weight: 800; +} + +.analysis-note { + margin: 14px 0; + color: #64748b; + font-size: 12px; + line-height: 1.5; +} + +.analysis-action { + margin-top: 4px; +} + +.analysis-error { + padding: 10px; + border: 1px solid #fecaca; + border-radius: 8px; + background: #fef2f2; + color: #991b1b; + overflow-wrap: anywhere; +} + +.analysis-progress { + position: relative; + height: 5px; + margin: 16px 0; + overflow: hidden; + border-radius: 999px; + background: #dbeafe; +} + +.analysis-progress::after { + position: absolute; + inset: 0; + width: 40%; + border-radius: inherit; + background: #2563eb; + content: ""; + animation: analysis-progress 1.2s ease-in-out infinite; +} + +@keyframes analysis-progress { + from { + transform: translateX(-110%); + } + + to { + transform: translateX(360%); + } +} + +@media (prefers-reduced-motion: reduce) { + .analysis-progress::after { + animation: none; + width: 100%; + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a2db340..a9e565c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -29,6 +29,7 @@ import { parseGraphJsonText, serializeGraphJson, } from "./graph/graphJson"; +import { useGraphAnalysis } from "./graph/useGraphAnalysis"; interface GraphNode { id: string; @@ -54,6 +55,7 @@ interface GraphData { } type OverviewLayout = "hierarchical" | "grid"; +type SidePanelMode = "inspector" | "analysis"; interface InspectorInfo { type: "node" | "edge"; @@ -87,6 +89,9 @@ function App() { const [pinnedInspector, setPinnedInspector] = useState(null); const [pathMode, setPathMode] = useState("idle"); const [selectedPath, setSelectedPath] = useState(null); + const [sidePanelMode, setSidePanelMode] = + useState("inspector"); + const graphAnalysis = useGraphAnalysis(); function updatePathMode(mode: PathSelectionMode) { pathModeRef.current = mode; @@ -671,6 +676,24 @@ function App() { } } + function runGraphAnalysis() { + const graph = graphRef.current; + + if (!graph) { + setStatus("Open a graph before running analysis"); + return; + } + + graphAnalysis.run({ + nodeIds: graph.nodes.map((node) => node.id), + edges: graph.edges.map((edge) => ({ + id: edge.id, + source: edge.source, + target: edge.target, + })), + }); + } + async function handleFileSelected(event: ChangeEvent) { const file = event.target.files?.[0]; @@ -678,6 +701,7 @@ function App() { return; } + graphAnalysis.reset(); setStatus(`Loading ${file.name}...`); setFileName(file.name); setInspectorInfo(null); @@ -1171,54 +1195,185 @@ function App() { diff --git a/frontend/src/graph/useGraphAnalysis.ts b/frontend/src/graph/useGraphAnalysis.ts new file mode 100644 index 0000000..5d1ad2b --- /dev/null +++ b/frontend/src/graph/useGraphAnalysis.ts @@ -0,0 +1,67 @@ +import { + useCallback, + useEffect, + useRef, + useState, +} from "react"; + +import AnalysisWorker from "../workers/graphAnalysis.worker?worker&inline"; +import type { + GraphAnalysisInput, +} from "./graphAnalysis"; +import { + GraphAnalysisController, + type GraphAnalysisState, +} from "./graphAnalysisController"; + +const INITIAL_STATE: GraphAnalysisState = { + status: "not-run", + result: null, + error: null, +}; + +export type UseGraphAnalysisResult = GraphAnalysisState & { + run: (input: GraphAnalysisInput) => void; + cancel: () => void; + reset: () => void; +}; + +export function useGraphAnalysis(): UseGraphAnalysisResult { + const [state, setState] = + useState(INITIAL_STATE); + const controllerRef = + useRef(null); + + useEffect(() => { + const controller = new GraphAnalysisController( + () => new AnalysisWorker(), + setState, + ); + + controllerRef.current = controller; + + return () => { + controller.dispose(); + controllerRef.current = null; + }; + }, []); + + const run = useCallback((input: GraphAnalysisInput) => { + controllerRef.current?.run(input); + }, []); + + const cancel = useCallback(() => { + controllerRef.current?.cancel(); + }, []); + + const reset = useCallback(() => { + controllerRef.current?.reset(); + }, []); + + return { + ...state, + run, + cancel, + reset, + }; +} From fca5571228652eddb22287b2ed3846e4cb523751 Mon Sep 17 00:00:00 2001 From: dbera Date: Thu, 6 Aug 2026 17:28:59 +0200 Subject: [PATCH 07/16] Add terminal state analysis navigation --- frontend/src/App.css | 72 ++++++++++++++++++++++++ frontend/src/App.tsx | 131 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 201 insertions(+), 2 deletions(-) diff --git a/frontend/src/App.css b/frontend/src/App.css index 1e381c6..5337596 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -479,3 +479,75 @@ button.active { width: 100%; } } + +.analysis-result-group { + margin: 16px 0; + border: 1px solid #cbd5e1; + border-radius: 8px; + background: #ffffff; +} +.analysis-result-group summary { + padding: 11px 12px; + color: #1e293b; + font-size: 13px; + font-weight: 750; + cursor: pointer; +} +.analysis-result-content { + padding: 0 12px 12px; + border-top: 1px solid #e2e8f0; +} +.analysis-filter-label { + display: block; + margin: 12px 0 6px; + color: #475569; + font-size: 12px; + font-weight: 700; +} +.analysis-filter-input { + width: 100%; + padding: 8px 10px; + border: 1px solid #cbd5e1; + border-radius: 7px; + outline: none; +} +.analysis-filter-input:focus { + border-color: #2563eb; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.13); +} +.analysis-result-range { + margin: 12px 0 8px; + color: #64748b; + font-size: 12px; +} +.terminal-state-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(76px, 1fr)); + gap: 6px; +} +.terminal-state-list button { + min-width: 0; + padding: 7px 8px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: Consolas, "Courier New", monospace; + font-size: 12px; +} +.analysis-pagination { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-top: 12px; +} +.analysis-pagination span { + color: #64748b; + font-size: 11px; + white-space: nowrap; +} +.analysis-pagination button { + padding: 6px 8px; + font-size: 11px; +} + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a9e565c..0e629d6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -64,6 +64,8 @@ interface InspectorInfo { data: JsonValue; } +const TERMINAL_PAGE_SIZE = 100; + function App() { const graphContainer = useRef(null); const fileInput = useRef(null); @@ -92,6 +94,10 @@ function App() { const [sidePanelMode, setSidePanelMode] = useState("inspector"); const graphAnalysis = useGraphAnalysis(); + const [terminalStatesExpanded, setTerminalStatesExpanded] = + useState(false); + const [terminalStateFilter, setTerminalStateFilter] = useState(""); + const [terminalStatePage, setTerminalStatePage] = useState(0); function updatePathMode(mode: PathSelectionMode) { pathModeRef.current = mode; @@ -702,6 +708,9 @@ function App() { } graphAnalysis.reset(); + setTerminalStatesExpanded(false); + setTerminalStateFilter(""); + setTerminalStatePage(0); setStatus(`Loading ${file.name}...`); setFileName(file.name); setInspectorInfo(null); @@ -1063,6 +1072,43 @@ function App() { target: edge.target, })); })(); + + const filteredTerminalNodeIds = (() => { + const terminalNodeIds = graphAnalysis.result?.terminalNodeIds ?? []; + const filter = terminalStateFilter.trim().toLocaleLowerCase(); + + if (!filter) return terminalNodeIds; + + return terminalNodeIds.filter((nodeId) => + nodeId.toLocaleLowerCase().includes(filter) + ); + })(); + const terminalPageCount = Math.max( + 1, + Math.ceil(filteredTerminalNodeIds.length / TERMINAL_PAGE_SIZE) + ); + const safeTerminalStatePage = Math.min( + terminalStatePage, + terminalPageCount - 1 + ); + const visibleTerminalNodeIds = filteredTerminalNodeIds.slice( + safeTerminalStatePage * TERMINAL_PAGE_SIZE, + (safeTerminalStatePage + 1) * TERMINAL_PAGE_SIZE + ); + const terminalResultStart = + filteredTerminalNodeIds.length === 0 + ? 0 + : safeTerminalStatePage * TERMINAL_PAGE_SIZE + 1; + const terminalResultEnd = Math.min( + (safeTerminalStatePage + 1) * TERMINAL_PAGE_SIZE, + filteredTerminalNodeIds.length + ); + + function focusTerminalState(stateId: string) { + showNeighborhood(stateId, hopCount); + setStatus(`Focused terminal state ${stateId}`); + } + return (
@@ -1335,9 +1381,90 @@ function App() {
{graphAnalysis.result.largestCyclicComponentSize}
+
+ setTerminalStatesExpanded(event.currentTarget.open) + } + > + + Terminal states ({graphAnalysis.result.terminalNodeIds.length}) + +
+ + { + setTerminalStateFilter(event.target.value); + setTerminalStatePage(0); + }} + placeholder="Enter all or part of an ID" + /> + {filteredTerminalNodeIds.length === 0 ? ( +

+ No terminal states match this filter. +

+ ) : ( + <> +

+ Showing {terminalResultStart}-{terminalResultEnd} of{" "} + {filteredTerminalNodeIds.length} +

+
+ {visibleTerminalNodeIds.map((nodeId) => ( + + ))} +
+ {terminalPageCount > 1 && ( +
+ + + Page {safeTerminalStatePage + 1} of {terminalPageCount} + + +
+ )} + + )} +
+

- Result navigation and component highlighting will be added in the - next iteration. + Terminal-state navigation uses the current neighborhood depth. + Cyclic-component navigation will be added next.

+ ))} + + {cyclicComponentPageCount > 1 && ( +
+ + + Page {safeCyclicComponentPage + 1} of{" "} + {cyclicComponentPageCount} + + +
+ )} + + )} + {selectedCyclicComponent && ( +
+
+ + Cyclic component {getCyclicComponentNumber(selectedCyclicComponent.id)} + + + {selectedCyclicComponent.nodeIds.length} states and{" "} + {selectedCyclicComponent.internalEdgeIds.length} internal + transitions + +
+ +
+ )} + +

Terminal-state navigation uses the current neighborhood depth. - Cyclic-component navigation will be added next. + Selecting a cyclic component shows only its states and internal + transitions.

+ {sidePanelMode === "inspector" ? ( @@ -1426,7 +1635,7 @@ function App() { )} - ) : ( + ) : sidePanelMode === "analysis" ? (
Graph analysis @@ -1733,6 +1942,115 @@ function App() {
)}
+ ) : ( +
+
+
+ Alternative paths +

Bounded shortest paths

+
+ {computedPathViewActive && ( + + )} +
+ {!graphLoaded ? ( +

Open a graph before searching for paths.

+ ) : ( + <> +
+ + setPathSearchSource(event.target.value)} disabled={pathSearch.status === "running"} /> + + setPathSearchTarget(event.target.value)} disabled={pathSearch.status === "running"} /> +
+
+ + setRequestedPathCount(Math.max(1, Number.parseInt(event.target.value, 10) || 1))} disabled={pathSearch.status === "running"} /> +
+
+ + setMaximumVisitsPerState(Math.max(1, Number.parseInt(event.target.value, 10) || 1))} disabled={pathSearch.status === "running"} /> +
+
+

A visit limit of 1 produces loopless paths. Higher values allow bounded revisits. Paths are unique by ordered edge IDs.

+ {pathSearch.status === "running" ? ( + + ) : ( + + )} +
+ {pathSearch.status === "running" &&

Searching for paths...

} + {pathSearch.status === "cancelled" &&

Path search was cancelled.

} + {pathSearch.status === "failed" &&
{pathSearch.error ?? "Path search failed."}
} + {pathSearch.status === "completed" && pathSearch.result && ( +
+
+ {pathSearch.result.paths.length} path{pathSearch.result.paths.length === 1 ? "" : "s"} found + {pathSearch.result.expandedCandidateCount} candidates expanded +
+ {pathSearch.result.paths.length === 0 ? ( +

No path satisfies the selected visit bound.

+ ) : ( +
+ {pathSearch.result.paths.map((path, index) => { + const steps = getComputedPathSteps(path); + + return ( +
+ +
+ Show transition details + {steps.length === 0 ? ( +

Zero-transition path at state {path.startNodeId}.

+ ) : ( +
    + {steps.map((step) => ( +
  1. + {step.transition} + + {step.source} → {step.target} + + {step.id} +
  2. + ))} +
+ )} +
+
+ ); + })} +
+ )} + {pathSearch.result.resourceLimitReached &&

Search stopped at the internal resource limit. Additional valid paths may exist.

} + {pathSearch.result.exhausted && pathSearch.result.paths.length < requestedPathCount &&

The bounded search space was exhausted. No additional paths exist for this visit limit.

} +
+ )} + + )} +
)} From 3b05225c50ffe8cca179177903fb046b527f5128 Mon Sep 17 00:00:00 2001 From: dbera Date: Fri, 7 Aug 2026 00:30:20 +0200 Subject: [PATCH 15/16] Complete bounded alternative path search --- frontend/src/App.css | 61 ++++++++++++++ frontend/src/App.tsx | 111 ++++++++++++++++++++++++-- frontend/src/graph/pathSearch.test.ts | 56 ++++++++++++- frontend/src/graph/pathSearch.ts | 51 ++++++++++++ 4 files changed, 273 insertions(+), 6 deletions(-) diff --git a/frontend/src/App.css b/frontend/src/App.css index a978fb0..1257f6d 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -651,3 +651,64 @@ button.active { line-height: 1.25; } +.computed-path-details li.active { + margin-left: -4px; + padding: 6px 7px; + border-left: 3px solid #2563eb; + border-radius: 4px; + background: #dbeafe; +} + +.computed-step-transition, +.computed-step-edge-id, +.computed-step-route button { + min-width: 0; + padding: 0; + border: 0; + border-radius: 2px; + background: transparent; + text-align: left; +} + +.computed-step-transition { + display: block; + width: 100%; + color: #1e293b; + font-size: 11px; + font-weight: 750; + overflow-wrap: anywhere; +} + +.computed-step-route { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 5px; + margin-top: 3px; + color: #64748b; +} + +.computed-step-route button { + color: #2563eb; + font-family: Consolas, "Courier New", monospace; + font-size: 10px; + font-weight: 700; +} + +.computed-step-edge-id { + display: block; + width: 100%; + margin-top: 3px; + color: #475569; + font-family: Consolas, "Courier New", monospace; + font-size: 10px; + overflow-wrap: anywhere; +} + +.computed-step-transition:hover, +.computed-step-edge-id:hover, +.computed-step-route button:hover { + color: #1d4ed8; + text-decoration: underline; +} + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c9a6fb2..5f9423f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -118,6 +118,7 @@ function App() { const [maximumVisitsPerState, setMaximumVisitsPerState] = useState(1); const [shownSearchPathIndex, setShownSearchPathIndex] = useState(null); const [computedPathViewActive, setComputedPathViewActive] = useState(false); + const [focusedComputedStepKey, setFocusedComputedStepKey] = useState(null); const [terminalStatesExpanded, setTerminalStatesExpanded] = useState(false); const [terminalStateFilter, setTerminalStateFilter] = useState(""); @@ -847,6 +848,7 @@ function App() { setHopCount(snapshot.hopCount); setOverviewLayout(snapshot.overviewLayout); setShownSearchPathIndex(null); + setFocusedComputedStepKey(null); setComputedPathViewActive(false); graphViewBeforeComputedPathRef.current = null; setStatus("Returned to the previous graph view"); @@ -867,6 +869,7 @@ function App() { updatePathMode("idle"); showPathContext(selected); setShownSearchPathIndex(index); + setFocusedComputedStepKey(null); setComputedPathViewActive(true); window.setTimeout(() => { const cy = cyRef.current; @@ -893,6 +896,7 @@ function App() { graphAnalysis.reset(); pathSearch.reset(); setShownSearchPathIndex(null); + setFocusedComputedStepKey(null); setComputedPathViewActive(false); graphViewBeforeComputedPathRef.current = null; setTerminalStatesExpanded(false); @@ -1403,6 +1407,52 @@ function App() { if (stateId) showNeighborhood(stateId, hopCount); } + function focusComputedPathEdge(edgeId: string, stepKey: string) { + const cy = cyRef.current; + const edge = cy?.getElementById(edgeId); + if (!cy || !edge || edge.empty()) { + setStatus(`Transition ${edgeId} is not visible. Show the path first.`); + return; + } + + cy.elements().unselect(); + edge.select(); + const info = makeEdgeInspector(edge); + pinnedInspectorRef.current = info; + setPinnedInspector(info); + setInspectorInfo(info); + setFocusedComputedStepKey(stepKey); + cy.animate({ + center: { eles: edge }, + duration: 250, + }); + setStatus(`Focused transition ${edge.data("transition") ?? edgeId}`); + } + + function focusComputedPathNode(nodeId: string, stepKey: string) { + const cy = cyRef.current; + const node = cy?.getElementById(nodeId); + if (!cy || !node || node.empty()) { + setStatus(`State ${nodeId} is not visible. Show the path first.`); + return; + } + + cy.elements().unselect(); + node.select(); + const info = makeNodeInspector(node); + pinnedInspectorRef.current = info; + setPinnedInspector(info); + setInspectorInfo(info); + setFocusedComputedStepKey(stepKey); + setSelectedStateId(nodeId); + setSearchText(nodeId); + cy.animate({ + center: { eles: node }, + duration: 250, + }); + setStatus(`Focused state ${nodeId}`); + } + function getComputedPathSteps(path: BoundedPath) { const graph = graphRef.current; if (!graph) return []; @@ -2028,12 +2078,63 @@ function App() { ) : (
    {steps.map((step) => ( -
  1. - {step.transition} - - {step.source} → {step.target} +
  2. + + + + + - {step.id} +
  3. ))}
diff --git a/frontend/src/graph/pathSearch.test.ts b/frontend/src/graph/pathSearch.test.ts index 74a0a26..9f437db 100644 --- a/frontend/src/graph/pathSearch.test.ts +++ b/frontend/src/graph/pathSearch.test.ts @@ -296,6 +296,8 @@ describe("findKShortestBoundedPaths", () => { { id: "ab", source: "A", target: "B" }, { id: "ac", source: "A", target: "C" }, { id: "ad", source: "A", target: "D" }, + { id: "bd", source: "B", target: "D" }, + { id: "cd", source: "C", target: "D" }, ], ), { maximumQueuedCandidates: 1 }, @@ -398,6 +400,58 @@ describe("findKShortestBoundedPaths", () => { expect(input).toEqual(original); }); + + it("prunes large branches that cannot reach the target", () => { + const deadEndCount = 20_000; + const nodeIds = [ + "source", + "target", + ...Array.from({ length: deadEndCount }, (_, index) => `dead-${index}`), + ]; + const edges: PathSearchEdge[] = [ + { id: "direct", source: "source", target: "target" }, + ...Array.from({ length: deadEndCount }, (_, index) => ({ + id: `dead-edge-${index}`, + source: "source", + target: `dead-${index}`, + })), + ]; + + const result = findKShortestBoundedPaths( + searchInput(nodeIds, edges, { + sourceNodeId: "source", + targetNodeId: "target", + requestedPathCount: 1, + }), + { maximumQueuedCandidates: 10 }, + ); + + expect(result.paths.map((path) => path.edgeIds)).toEqual([["direct"]]); + expect(result.resourceLimitReached).toBe(false); + expect(result.expandedCandidateCount).toBe(2); + }); + + it("uses reverse distance guidance while preserving shortest-first order", () => { + const result = findKShortestBoundedPaths( + searchInput( + ["source", "near", "far-1", "far-2", "target"], + [ + { id: "to-far", source: "source", target: "far-1" }, + { id: "far-step", source: "far-1", target: "far-2" }, + { id: "far-target", source: "far-2", target: "target" }, + { id: "to-near", source: "source", target: "near" }, + { id: "near-target", source: "near", target: "target" }, + ], + { requestedPathCount: 2 }, + ), + ); + + expect(result.paths.map((path) => path.edgeIds)).toEqual([ + ["to-near", "near-target"], + ["to-far", "far-step", "far-target"], + ]); + }); + it("handles a long linear graph", () => { const nodeCount = 10_000; const nodeIds = Array.from( @@ -419,5 +473,5 @@ describe("findKShortestBoundedPaths", () => { expect(result.paths).toHaveLength(1); expect(result.paths[0].edgeIds).toHaveLength(nodeCount - 1); - }, 15000); + }); }); diff --git a/frontend/src/graph/pathSearch.ts b/frontend/src/graph/pathSearch.ts index dbe5a03..3ae9f73 100644 --- a/frontend/src/graph/pathSearch.ts +++ b/frontend/src/graph/pathSearch.ts @@ -64,6 +64,7 @@ export type PathSearchOptions = { type NormalizedTopology = { nodeIds: string[]; outgoingEdgesByNodeId: Map; + incomingNodeIdsByNodeId: Map; }; type ConstraintProgress = { @@ -75,6 +76,7 @@ type SearchCandidate = { parent: SearchCandidate | null; incomingEdgeId: string | null; depth: number; + estimatedTotalCost: number; constraintProgress: ConstraintProgress; insertionSequence: number; }; @@ -163,6 +165,10 @@ function hasHigherPriority( left: SearchCandidate, right: SearchCandidate, ): boolean { + if (left.estimatedTotalCost !== right.estimatedTotalCost) { + return left.estimatedTotalCost < right.estimatedTotalCost; + } + if (left.depth !== right.depth) { return left.depth < right.depth; } @@ -207,9 +213,11 @@ function buildTopology(input: PathSearchInput): NormalizedTopology { } const outgoingEdgesByNodeId = new Map(); + const incomingNodeIdsByNodeId = new Map(); for (const nodeId of nodeIds) { outgoingEdgesByNodeId.set(nodeId, []); + incomingNodeIdsByNodeId.set(nodeId, []); } const knownEdgeIds = new Set(); @@ -234,14 +242,44 @@ function buildTopology(input: PathSearchInput): NormalizedTopology { } outgoingEdgesByNodeId.get(edge.source)?.push(edge); + incomingNodeIdsByNodeId.get(edge.target)?.push(edge.source); } return { nodeIds, outgoingEdgesByNodeId, + incomingNodeIdsByNodeId, }; } +function computeDistancesToTarget( + topology: NormalizedTopology, + targetNodeId: string, +): Map { + const distances = new Map([[targetNodeId, 0]]); + const queue: string[] = [targetNodeId]; + let queueIndex = 0; + + while (queueIndex < queue.length) { + const nodeId = queue[queueIndex]; + queueIndex += 1; + const nextDistance = (distances.get(nodeId) ?? 0) + 1; + const predecessors = + topology.incomingNodeIdsByNodeId.get(nodeId) ?? []; + + for (const predecessor of predecessors) { + if (distances.has(predecessor)) { + continue; + } + + distances.set(predecessor, nextDistance); + queue.push(predecessor); + } + } + + return distances; +} + function validateResourceLimit( value: number | undefined, fallback: number, @@ -316,6 +354,10 @@ export function findKShortestBoundedPaths( options: PathSearchOptions = {}, ): PathSearchResult { const topology = buildTopology(input); + const distancesToTarget = computeDistancesToTarget( + topology, + input.targetNodeId, + ); const maximumExpandedCandidates = validateResourceLimit( options.maximumExpandedCandidates, DEFAULT_MAXIMUM_EXPANDED_CANDIDATES, @@ -339,6 +381,8 @@ export function findKShortestBoundedPaths( parent: null, incomingEdgeId: null, depth: 0, + estimatedTotalCost: + distancesToTarget.get(input.sourceNodeId) ?? Number.POSITIVE_INFINITY, constraintProgress: { nextRequiredTransitionIndex: 0, }, @@ -393,6 +437,12 @@ export function findKShortestBoundedPaths( topology.outgoingEdgesByNodeId.get(candidate.currentNodeId) ?? []; for (const edge of outgoingEdges) { + const remainingDistance = distancesToTarget.get(edge.target); + + if (remainingDistance === undefined) { + continue; + } + if (queue.size >= maximumQueuedCandidates) { resourceLimitReached = true; break; @@ -419,6 +469,7 @@ export function findKShortestBoundedPaths( parent: candidate, incomingEdgeId: edge.id, depth: candidate.depth + 1, + estimatedTotalCost: candidate.depth + 1 + remainingDistance, constraintProgress: nextConstraintProgress, insertionSequence: nextInsertionSequence, }); From 82c51ecfb6794949d92c514b3613df99a931f3da Mon Sep 17 00:00:00 2001 From: dbera Date: Fri, 7 Aug 2026 00:37:52 +0200 Subject: [PATCH 16/16] Document bounded alternative path search --- CHANGELOG.md | 24 ++++++++++++++++++++++++ README.md | 53 ++++++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 69 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d0e13a..4c7b789 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Added large synthetic tests for long acyclic graphs and large strongly connected components. - Added worker-controller tests for success, failure, cancellation, reruns, stale responses, malformed responses, reset, and disposal. - Added `sample-data/synthetic.json` with known terminal-state and SCC results for manual verification. +- Added a **Paths** tab for a user-defined number of shortest paths between source and target states. +- Added configurable maximum visits per state; `1` produces loopless paths and higher values permit bounded revisits. +- Added equal-source-and-target support, including zero-transition paths and bounded returning cycles. +- Added edge-ID-sequence uniqueness so parallel transitions produce distinct alternatives. +- Added deterministic shortest-first ordering by transition count. +- Added reverse-distance-guided search to prioritize reachable alternatives and prune states that cannot reach the target. +- Added internal candidate safeguards with partial-result reporting when additional paths may exist. +- Added an inline path-search Web Worker with cancellation, stale-response protection, reruns, errors, reset, and disposal. +- Added path-search and worker-controller tests covering ordering, revisits, self-loops, parallel edges, equal endpoints, safeguards, cancellation, long paths, reverse-distance pruning, and lifecycle behavior. +- Added computed-path navigation using existing visualization and JSON and PlantUML exports. +- Added expandable transition details with names, source and target states, and exact edge IDs. +- Added clickable result details that center and select graph edges or states while keeping the Paths tab open. +- Added graph-view snapshots and **Return to graph view** restoration for visible elements, positions, zoom, pan, focus, neighborhood depth, and layout. +- Added separate curved rendering for parallel transitions. ### Changed @@ -28,6 +42,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Kept terminal-state terminology separate from deadlock classification because successful completion and unintended deadlock cannot be distinguished from topology alone. - Updated path selection to leave a component-only graph view before path construction begins. - Preserved analysis results while switching between Inspector and Analysis tabs or navigating individual results. +- Preserved path-search results while switching among Inspector, Analysis, and Paths. +- Kept computed-path inspection non-disruptive by pinning Inspector data without switching tabs automatically. +- Reused the selected-path representation so computed paths retain ordered edge IDs, loops, repeated traversals, parallel-edge identity, semantic data, and export behavior. +- Kept computed-path viewport fitting separate from layout so selecting a result does not rearrange states. +- Reserved the path-search model for future ordered transition constraints and partial transition-data matching. ### Fixed @@ -35,6 +54,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Fixed cyclic components being displayed with confusing internal SCC IDs instead of sequential user-facing numbers. - Fixed stale worker responses being able to affect a newer analysis request. - Fixed worker cleanup during cancellation, reset, rerun, and component unmounting. +- Fixed dense searches wasting candidate capacity on branches that cannot reach the target by adding reverse-distance pruning. +- Fixed long-path candidate construction repeatedly copying complete path data by using parent-linked candidates and reconstructing edge IDs only for completed paths. +- Fixed computed paths being difficult to locate by fitting the viewport without changing node positions. +- Fixed leaving a computed-path view requiring manual graph reconstruction by restoring the saved graph view explicitly. +- Fixed edge-unique paths with identical state sequences being visually indistinguishable by separating parallel curves and exposing exact transition details. ## [0.4.0](https://github.com/dbera/LTSVisualizer/compare/v0.3.0...v0.4.0) - 2026-08-06 diff --git a/README.md b/README.md index c037c22..2dc70dc 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,27 @@ A terminal state is not automatically an error. Whether a terminal state represe Analysis results are held in browser memory for the currently loaded graph. They are reset when another graph is opened and are not added to graph or selected-path exports. +### Bounded alternative path search + +- Open the **Paths** tab to compute up to a user-defined number of shortest paths between two states. +- Set **Visits per state** to `1` for loopless paths or to a higher value to allow bounded revisits. +- Support equal source and target states. The zero-transition path is returned first, and returning cycles may follow when the visit bound permits them. +- Treat paths as unique by ordered edge-ID sequence, so parallel transitions remain distinct even when they connect the same states. +- Order results by increasing transition count, with deterministic ordering for equal-length alternatives. +- Use reverse shortest-distance guidance from the target to prioritize reachable alternatives and prune states that cannot reach the target. +- Run searches in an inline Web Worker with cancellation, stale-result protection, errors, reruns, and reset when another graph is loaded. +- Stop safely at internal candidate safeguards and report partial results without claiming that no additional paths exist. +- Select a result to reuse existing path visualization and JSON and PlantUML exports. +- Separate parallel transitions visually and expose transition names, source and target states, and exact edge IDs. +- Select transition or state details to center and select the corresponding graph element without leaving the **Paths** tab. +- Pin clicked transition or state data for later viewing in Inspector without switching tabs automatically. +- Preserve search results while switching among Inspector, Analysis, and Paths. +- Fit the viewport around a computed path without changing node positions. +- Use **Return to graph view** to restore visible elements, positions, zoom, pan, focus, neighborhood depth, and layout while retaining results. +- Use the same functionality in hosted and offline `file:///` builds. + +Path-search results are kept in browser memory for the currently loaded graph and are reset when another graph is opened. + ### Manual path selection - Start a path from the currently focused state. @@ -384,6 +405,21 @@ For very large graphs, neighborhood exploration is recommended instead of displa For large graphs, worker execution prevents the analysis algorithm from blocking the main browser interface. Preparing and transferring graph topology still consumes browser memory, so analysis remains an explicit user action. +### Find alternative paths + +1. Open the **Paths** tab. +2. Enter source and target state IDs. +3. Choose the requested number of paths. +4. Set **Visits per state** to `1` for loopless paths or higher for bounded revisits. +5. Select **Find paths**. During a running search, the action changes to **Cancel**. +6. Select a result to display it without relaying out its states. +7. Expand **Show transition details** to compare transition names, state pairs, and edge IDs. +8. Select a transition name or edge ID to center and select its edge, or select a state ID to center and select its state. +9. Use **Export .puml** or **Export .json** to export the displayed computed path. +10. Select **Return to graph view** to restore the prior graph context without clearing results. + +Paths are ordered by transition count and are unique by ordered edge IDs. If source and target are equal, the zero-transition path is valid. + ### Select a path 1. Search for or focus the desired starting state. @@ -487,6 +523,8 @@ React and TypeScript application |-- Structured semantic-data inspection |-- On-demand terminal-state and SCC analysis | `-- Inline Web Worker with cancellation + |-- Bounded alternative path search + | `-- Reverse-distance-guided inline Web Worker with cancellation |-- Manual path selection |-- Complete-graph JSON export |-- Selected-path JSON export @@ -594,7 +632,7 @@ npm run build npm run build:offline ``` -The current test suite covers JSON validation and round trips, graph serialization, complete-graph export, path selection, loops, repeated states, parallel edges, selected-path export, semantic data, PlantUML path export, terminal-state detection, iterative SCC computation, large synthetic graph topologies, and worker-controller lifecycle behavior. +The current test suite covers JSON validation and round trips, graph serialization, complete-graph export, manual and computed path selection, loops, repeated states, bounded revisits, source-equals-target paths, self-loops, parallel edges, deterministic shortest-first ordering, reverse-distance pruning, resource safeguards, selected-path export, semantic data, PlantUML path export, terminal-state detection, iterative SCC computation, large synthetic graph topologies, and worker-controller lifecycle behavior. ## Build targets @@ -686,6 +724,8 @@ The tag triggers the offline HTML release workflow and publishes `LTSVisualizer. - Global force-directed layouts are intentionally avoided because they can be computationally expensive in the browser. - Terminal states are reported topologically and are not classified as successful completions or definite deadlocks. - Graph analysis uses a worker and is user-triggered, but very large graphs still require additional browser memory for topology transfer and analysis results. +- Bounded path search is user-triggered and uses a worker, but highly connected graphs can still reach internal candidate safeguards before every requested alternative is found. Partial results are reported and additional valid paths may exist. +- Cancelling path search terminates its worker immediately; partial paths found before cancellation are not retained. - The offline release depends on browser support for local `file:///` applications and file selection. - GitHub Pages availability depends on successful processing by GitHub's deployment service. @@ -693,17 +733,14 @@ The tag triggers the offline HTML release workflow and publishes `LTSVisualizer. Planned priority: -1. Experiment with constrained graph search. +1. Extend bounded alternative path search with transition constraints. -The constrained graph-search experiment may support: +The future constrained-search extension may support: -- Start and optional target states - Required transitions in order +- Required transitions matched by transition name and specific or partial structured input and output data - Forbidden transitions -- Maximum path length -- Shortest matching paths -- Loops and parallel transitions -- Reuse of the existing path visualization and export functionality +- Additional constraint combinations while preserving bounded revisits, parallel-edge identity, shortest-first results, and existing visualization and export functionality Additional potential improvements include: