From fb921f9a2911359ff7362531d9eda0639e033725 Mon Sep 17 00:00:00 2001 From: "glm-5.1" Date: Thu, 21 May 2026 21:58:30 +0000 Subject: [PATCH] feat: add execution ordering analysis functions (topologicalOrder, parallelGroups, criticalPath, reachableFrom, ancestors, descendants) --- src/analysis/index.ts | 8 + src/analysis/ordering.ts | 86 +++++++++++ test/analysis/ordering.test.ts | 270 +++++++++++++++++++++++++++++++++ 3 files changed, 364 insertions(+) create mode 100644 src/analysis/ordering.ts create mode 100644 test/analysis/ordering.test.ts diff --git a/src/analysis/index.ts b/src/analysis/index.ts index 4083db8..bb7f514 100644 --- a/src/analysis/index.ts +++ b/src/analysis/index.ts @@ -5,6 +5,14 @@ export { resolveDefaultNodeAttrs, } from "./defaults.js"; export { typeCompat, type TypeCompatResult, type TypeMismatch } from "./type-compat.js"; +export { + topologicalOrder, + parallelGroups, + criticalPath, + reachableFrom, + ancestors, + descendants, +} from "./ordering.js"; export { buildTypeEdges } from "../graph/construction.js"; export { validateSchema, diff --git a/src/analysis/ordering.ts b/src/analysis/ordering.ts new file mode 100644 index 0000000..7a6c609 --- /dev/null +++ b/src/analysis/ordering.ts @@ -0,0 +1,86 @@ +import { topologicalSort, topologicalGenerations, hasCycle } from "graphology-dag"; +import type { FlowGraph } from "../graph/construction.js"; +import { CycleError, NodeNotFoundError } from "../error/index.js"; +import { findCycles as findCyclesQuery, reachableFrom as reachableFromQuery, ancestors as ancestorsQuery, descendants as descendantsQuery } from "../graph/queries.js"; + +export function topologicalOrder(graph: FlowGraph): string[] { + if (hasCycle(graph.graph)) { + const cycles = findCyclesQuery(graph.graph); + throw new CycleError(cycles); + } + return topologicalSort(graph.graph); +} + +export function parallelGroups(graph: FlowGraph): string[][] { + if (hasCycle(graph.graph)) { + const cycles = findCyclesQuery(graph.graph); + throw new CycleError(cycles); + } + return topologicalGenerations(graph.graph); +} + +export function criticalPath(graph: FlowGraph): string[] { + if (hasCycle(graph.graph)) { + const cycles = findCyclesQuery(graph.graph); + throw new CycleError(cycles); + } + const dg = graph.graph; + const depth = new Map(); + const topo = topologicalSort(dg); + for (const node of topo) { + const inNeighbors = dg.inNeighbors(node) ?? []; + let maxPredDepth = -1; + for (const pred of inNeighbors) { + const predDepth = depth.get(pred!) ?? 0; + if (predDepth > maxPredDepth) { + maxPredDepth = predDepth; + } + } + depth.set(node, maxPredDepth + 1); + } + let maxDepth = -1; + let endNode = ""; + for (const node of topo) { + const d = depth.get(node) ?? 0; + if (d > maxDepth) { + maxDepth = d; + endNode = node; + } + } + if (topo.length === 0) return []; + const path: string[] = [endNode]; + let current = endNode; + while (depth.get(current)! > 0) { + const inNeighbors = dg.inNeighbors(current) ?? []; + let bestPred = ""; + let bestDepth = -1; + for (const pred of inNeighbors) { + const predDepth = depth.get(pred!) ?? 0; + if (predDepth > bestDepth) { + bestDepth = predDepth; + bestPred = pred!; + } + } + path.unshift(bestPred); + current = bestPred; + } + return path; +} + +export function reachableFrom(graph: FlowGraph, nodeIds: string[]): Set { + return reachableFromQuery(graph.graph, nodeIds); +} + +export function ancestors(graph: FlowGraph, nodeId: string): string[] { + if (!graph.hasNode(nodeId)) { + throw new NodeNotFoundError(nodeId); + } + return ancestorsQuery(graph.graph, nodeId); +} + +export function descendants(graph: FlowGraph, nodeId: string): string[] { + if (!graph.hasNode(nodeId)) { + throw new NodeNotFoundError(nodeId); + } + return descendantsQuery(graph.graph, nodeId); +} \ No newline at end of file diff --git a/test/analysis/ordering.test.ts b/test/analysis/ordering.test.ts new file mode 100644 index 0000000..03fb062 --- /dev/null +++ b/test/analysis/ordering.test.ts @@ -0,0 +1,270 @@ +import { describe, it, expect } from "vitest"; +import { FlowGraph } from "../../src/graph/construction.js"; +import { + topologicalOrder, + parallelGroups, + criticalPath, + reachableFrom, + ancestors, + descendants, +} from "../../src/analysis/ordering.js"; +import { CycleError, NodeNotFoundError } from "../../src/error/index.js"; + +function buildDiamondDag(): FlowGraph { + const fg = new FlowGraph(); + fg.addNode("top", { name: "top" }); + fg.addNode("left", { name: "left" }); + fg.addNode("right", { name: "right" }); + fg.addNode("bottom", { name: "bottom" }); + fg.addEdge("top", "left"); + fg.addEdge("top", "right"); + fg.addEdge("left", "bottom"); + fg.addEdge("right", "bottom"); + return fg; +} + +function buildChainDag(): FlowGraph { + const fg = new FlowGraph(); + fg.addNode("a", { name: "a" }); + fg.addNode("b", { name: "b" }); + fg.addNode("c", { name: "c" }); + fg.addNode("d", { name: "d" }); + fg.addEdge("a", "b"); + fg.addEdge("b", "c"); + fg.addEdge("c", "d"); + return fg; +} + +function buildWideDag(): FlowGraph { + const fg = new FlowGraph(); + fg.addNode("root", { name: "root" }); + fg.addNode("x", { name: "x" }); + fg.addNode("y", { name: "y" }); + fg.addNode("z", { name: "z" }); + fg.addEdge("root", "x"); + fg.addEdge("root", "y"); + fg.addEdge("root", "z"); + return fg; +} + +function buildDisconnectedDag(): FlowGraph { + const fg = new FlowGraph(); + fg.addNode("a", { name: "a" }); + fg.addNode("b", { name: "b" }); + fg.addNode("c", { name: "c" }); + fg.addEdge("a", "b"); + return fg; +} + +describe("topologicalOrder (analysis)", () => { + it("returns topological order for a chain", () => { + const fg = buildChainDag(); + expect(topologicalOrder(fg)).toEqual(["a", "b", "c", "d"]); + }); + + it("returns topological order for a diamond DAG", () => { + const fg = buildDiamondDag(); + const order = topologicalOrder(fg); + expect(order[0]).toBe("top"); + expect(order[3]).toBe("bottom"); + expect(order).toContain("left"); + expect(order).toContain("right"); + }); + + it("returns empty array for empty graph", () => { + const fg = new FlowGraph(); + expect(topologicalOrder(fg)).toEqual([]); + }); + + it("returns single node for graph with one node", () => { + const fg = new FlowGraph(); + fg.addNode("only", { name: "only" }); + expect(topologicalOrder(fg)).toEqual(["only"]); + }); + + it("throws CycleError when graph has cycles", () => { + const fg = new FlowGraph(); + fg.graph.addNode("a"); + fg.graph.addNode("b"); + fg.graph.addNode("c"); + fg.graph.addEdgeWithKey("a->b", "a", "b"); + fg.graph.addEdgeWithKey("b->c", "b", "c"); + fg.graph.addEdgeWithKey("c->a", "c", "a"); + expect(() => topologicalOrder(fg)).toThrow(CycleError); + }); +}); + +describe("parallelGroups (analysis)", () => { + it("groups chain nodes into sequential groups", () => { + const fg = buildChainDag(); + const groups = parallelGroups(fg); + expect(groups).toEqual([["a"], ["b"], ["c"], ["d"]]); + }); + + it("groups diamond DAG correctly", () => { + const fg = buildDiamondDag(); + const groups = parallelGroups(fg); + expect(groups.length).toBe(3); + expect(groups[0]).toEqual(["top"]); + expect(groups[1]!.sort()).toEqual(["left", "right"]); + expect(groups[2]).toEqual(["bottom"]); + }); + + it("groups wide DAG with root and leaves", () => { + const fg = buildWideDag(); + const groups = parallelGroups(fg); + expect(groups.length).toBe(2); + expect(groups[0]).toEqual(["root"]); + expect(groups[1]!.sort()).toEqual(["x", "y", "z"]); + }); + + it("groups disconnected DAG correctly", () => { + const fg = buildDisconnectedDag(); + const groups = parallelGroups(fg); + expect(groups.length).toBe(2); + expect(groups[0]!.sort()).toEqual(["a", "c"]); + expect(groups[1]).toEqual(["b"]); + }); + + it("returns empty array for empty graph", () => { + const fg = new FlowGraph(); + expect(parallelGroups(fg)).toEqual([]); + }); + + it("returns single group for single node with no edges", () => { + const fg = new FlowGraph(); + fg.addNode("only", { name: "only" }); + expect(parallelGroups(fg)).toEqual([["only"]]); + }); + + it("throws CycleError when graph has cycles", () => { + const fg = new FlowGraph(); + fg.graph.addNode("a"); + fg.graph.addNode("b"); + fg.graph.addEdgeWithKey("a->b", "a", "b"); + fg.graph.addEdgeWithKey("b->a", "b", "a"); + expect(() => parallelGroups(fg)).toThrow(CycleError); + }); +}); + +describe("criticalPath (analysis)", () => { + it("returns full chain for a chain DAG", () => { + const fg = buildChainDag(); + expect(criticalPath(fg)).toEqual(["a", "b", "c", "d"]); + }); + + it("returns a longest path for diamond DAG", () => { + const fg = buildDiamondDag(); + const path = criticalPath(fg); + expect(path.length).toBe(3); + expect(path[0]).toBe("top"); + expect(path[2]).toBe("bottom"); + expect(path[1] === "left" || path[1] === "right").toBe(true); + }); + + it("returns single node for graph with one node", () => { + const fg = new FlowGraph(); + fg.addNode("only", { name: "only" }); + expect(criticalPath(fg)).toEqual(["only"]); + }); + + it("returns empty array for empty graph", () => { + const fg = new FlowGraph(); + expect(criticalPath(fg)).toEqual([]); + }); + + it("returns correct path for wide DAG", () => { + const fg = buildWideDag(); + const path = criticalPath(fg); + expect(path.length).toBe(2); + expect(path[0]).toBe("root"); + }); + + it("throws CycleError when graph has cycles", () => { + const fg = new FlowGraph(); + fg.graph.addNode("a"); + fg.graph.addNode("b"); + fg.graph.addEdgeWithKey("a->b", "a", "b"); + fg.graph.addEdgeWithKey("b->a", "b", "a"); + expect(() => criticalPath(fg)).toThrow(CycleError); + }); +}); + +describe("reachableFrom (analysis)", () => { + it("returns all reachable nodes from a single start node", () => { + const fg = buildChainDag(); + expect(reachableFrom(fg, ["a"])).toEqual(new Set(["a", "b", "c", "d"])); + }); + + it("returns only the start node if it is a leaf", () => { + const fg = buildChainDag(); + expect(reachableFrom(fg, ["d"])).toEqual(new Set(["d"])); + }); + + it("returns union of reachable nodes from multiple start nodes", () => { + const fg = buildDiamondDag(); + expect(reachableFrom(fg, ["left", "right"])).toEqual(new Set(["left", "right", "bottom"])); + }); + + it("returns empty set for empty input", () => { + const fg = buildChainDag(); + expect(reachableFrom(fg, [])).toEqual(new Set()); + }); + + it("returns full reachable set from root in diamond", () => { + const fg = buildDiamondDag(); + expect(reachableFrom(fg, ["top"])).toEqual(new Set(["top", "left", "right", "bottom"])); + }); +}); + +describe("ancestors (analysis)", () => { + it("returns all ancestors for a node in a chain", () => { + const fg = buildChainDag(); + expect(ancestors(fg, "d")).toEqual(["c", "b", "a"]); + }); + + it("returns all ancestors for a node in a diamond DAG", () => { + const fg = buildDiamondDag(); + const anc = ancestors(fg, "bottom"); + expect(anc).toContain("left"); + expect(anc).toContain("right"); + expect(anc).toContain("top"); + expect(anc.length).toBe(3); + }); + + it("returns empty array for a root node", () => { + const fg = buildChainDag(); + expect(ancestors(fg, "a")).toEqual([]); + }); + + it("throws NodeNotFoundError for missing node", () => { + const fg = new FlowGraph(); + expect(() => ancestors(fg, "missing")).toThrow(NodeNotFoundError); + }); +}); + +describe("descendants (analysis)", () => { + it("returns all descendants for a node in a chain", () => { + const fg = buildChainDag(); + expect(descendants(fg, "a")).toEqual(["b", "c", "d"]); + }); + + it("returns all descendants for a node in a diamond DAG", () => { + const fg = buildDiamondDag(); + const desc = descendants(fg, "top"); + expect(desc).toContain("left"); + expect(desc).toContain("right"); + expect(desc).toContain("bottom"); + expect(desc.length).toBe(3); + }); + + it("returns empty array for a leaf node", () => { + const fg = buildChainDag(); + expect(descendants(fg, "d")).toEqual([]); + }); + + it("throws NodeNotFoundError for missing node", () => { + const fg = new FlowGraph(); + expect(() => descendants(fg, "missing")).toThrow(NodeNotFoundError); + }); +}); \ No newline at end of file