diff --git a/internal/engine/dependency_order.go b/internal/engine/dependency_order.go new file mode 100644 index 0000000..5d0b631 --- /dev/null +++ b/internal/engine/dependency_order.go @@ -0,0 +1,200 @@ +package engine + +import ( + "container/heap" + "sort" +) + +// dependencyKey is an immutable tuple used to order dependency-graph nodes. +// Callers provide unique keys and valid edge endpoints. +type dependencyKey []string + +// dependencyEdge points from a prerequisite to the node that depends on it. +type dependencyEdge struct { + prerequisite int + dependent int +} + +type dependencyReadyHeap struct { + nodes []int + keys []dependencyKey +} + +func (h dependencyReadyHeap) Len() int { return len(h.nodes) } + +func (h dependencyReadyHeap) Less(i, j int) bool { + return dependencyKeyLess(h.keys[h.nodes[i]], h.keys[h.nodes[j]]) +} + +func (h dependencyReadyHeap) Swap(i, j int) { + h.nodes[i], h.nodes[j] = h.nodes[j], h.nodes[i] +} + +func (h *dependencyReadyHeap) Push(value any) { + h.nodes = append(h.nodes, value.(int)) +} + +func (h *dependencyReadyHeap) Pop() any { + last := len(h.nodes) - 1 + node := h.nodes[last] + h.nodes = h.nodes[:last] + return node +} + +// dependencyKeyLess compares tuple components bytewise, with a shorter tuple +// sorting first when all of its components match the corresponding prefix. +func dependencyKeyLess(left, right dependencyKey) bool { + for i := 0; i < len(left) && i < len(right); i++ { + if left[i] != right[i] { + return left[i] < right[i] + } + } + return len(left) < len(right) +} + +// scheduleDependencies applies Kahn's algorithm, always selecting the +// smallest currently-ready key. Duplicate edges count only once. If scheduling +// stalls, an explicit depth-first traversal of the residual graph returns an +// actual directed cycle, rotated to its smallest key and closed by repeating +// its first node; blocked non-cycle dependents are excluded. +// +// Nodes are visited by precomputed key rank, which also makes each adjacency +// list deterministic without sorting edges. The work is O(E + V log V), with +// O(V + E) additional memory, apart from key copying and tuple comparisons. +func scheduleDependencies(keys []dependencyKey, edges []dependencyEdge) (order, cycle []int) { + ownedKeys := make([]dependencyKey, len(keys)) + for i, key := range keys { + ownedKeys[i] = append(dependencyKey(nil), key...) + } + + nodeOrder := make([]int, len(ownedKeys)) + for i := range nodeOrder { + nodeOrder[i] = i + } + sort.Slice(nodeOrder, func(i, j int) bool { + return dependencyKeyLess(ownedKeys[nodeOrder[i]], ownedKeys[nodeOrder[j]]) + }) + + incoming := make([][]int, len(ownedKeys)) + for _, edge := range edges { + incoming[edge.dependent] = append(incoming[edge.dependent], edge.prerequisite) + } + + outgoing := make([][]int, len(ownedKeys)) + indegree := make([]int, len(ownedKeys)) + for _, dependent := range nodeOrder { + seen := make(map[int]struct{}, len(incoming[dependent])) + for _, prerequisite := range incoming[dependent] { + if _, duplicate := seen[prerequisite]; duplicate { + continue + } + seen[prerequisite] = struct{}{} + indegree[dependent]++ + // Dependents are visited by key rank, so each outgoing list is + // already ordered by key. + outgoing[prerequisite] = append(outgoing[prerequisite], dependent) + } + } + + ready := dependencyReadyHeap{keys: ownedKeys} + for _, node := range nodeOrder { + if indegree[node] == 0 { + ready.nodes = append(ready.nodes, node) + } + } + heap.Init(&ready) + + order = make([]int, 0, len(ownedKeys)) + for ready.Len() != 0 { + node := heap.Pop(&ready).(int) + order = append(order, node) + for _, dependent := range outgoing[node] { + indegree[dependent]-- + if indegree[dependent] == 0 { + heap.Push(&ready, dependent) + } + } + } + if len(order) == len(ownedKeys) { + return order, nil + } + return nil, dependencyCycle(ownedKeys, nodeOrder, outgoing, indegree) +} + +type dependencyDFSFrame struct { + node int + next int +} + +// dependencyCycle searches the residual graph iteratively to avoid recursion +// depth depending on input size. +func dependencyCycle(keys []dependencyKey, nodeOrder []int, outgoing [][]int, indegree []int) []int { + const ( + unvisited = uint8(0) + active = uint8(1) + finished = uint8(2) + ) + + state := make([]uint8, len(keys)) + position := make([]int, len(keys)) + for i := range position { + position[i] = -1 + } + path := make([]int, 0, len(keys)) + frames := make([]dependencyDFSFrame, 0, len(keys)) + + for _, root := range nodeOrder { + if indegree[root] == 0 || state[root] != unvisited { + continue + } + state[root] = active + position[root] = len(path) + path = append(path, root) + frames = append(frames, dependencyDFSFrame{node: root}) + + for len(frames) != 0 { + frame := &frames[len(frames)-1] + if frame.next == len(outgoing[frame.node]) { + state[frame.node] = finished + position[frame.node] = -1 + path = path[:len(path)-1] + frames = frames[:len(frames)-1] + continue + } + + next := outgoing[frame.node][frame.next] + frame.next++ + if indegree[next] == 0 { + continue + } + switch state[next] { + case unvisited: + state[next] = active + position[next] = len(path) + path = append(path, next) + frames = append(frames, dependencyDFSFrame{node: next}) + case active: + return canonicalDependencyCycle(keys, path[position[next]:]) + } + } + } + + // A stalled Kahn traversal always leaves a cycle in the residual graph. + return nil +} + +func canonicalDependencyCycle(keys []dependencyKey, path []int) []int { + start := 0 + for i := 1; i < len(path); i++ { + if dependencyKeyLess(keys[path[i]], keys[path[start]]) { + start = i + } + } + + cycle := make([]int, len(path)+1) + for i := range path { + cycle[i] = path[(start+i)%len(path)] + } + cycle[len(path)] = cycle[0] + return cycle +} diff --git a/internal/engine/dependency_order_test.go b/internal/engine/dependency_order_test.go new file mode 100644 index 0000000..990c961 --- /dev/null +++ b/internal/engine/dependency_order_test.go @@ -0,0 +1,215 @@ +package engine + +import ( + "reflect" + "testing" +) + +func TestScheduleDependenciesOrdersReadyNodes(t *testing.T) { + tests := []struct { + name string + keys []string + edges [][2]string + want []string + }{ + { + name: "independent branches", + keys: []string{"branch-c", "branch-a", "branch-b"}, + want: []string{"branch-a", "branch-b", "branch-c"}, + }, + { + name: "unequal chains", + keys: []string{"chain-d", "chain-c", "chain-b", "chain-a"}, + edges: [][2]string{ + {"chain-a", "chain-b"}, + {"chain-b", "chain-c"}, + {"chain-c", "chain-d"}, + }, + want: []string{"chain-a", "chain-b", "chain-c", "chain-d"}, + }, + { + name: "multiple dependencies", + keys: []string{"application", "right", "base", "left"}, + edges: [][2]string{ + {"base", "left"}, {"base", "right"}, + {"left", "application"}, {"right", "application"}, + }, + want: []string{"base", "left", "right", "application"}, + }, + { + name: "shared dependency at equal depths", + keys: []string{"sink", "right", "root", "left"}, + edges: [][2]string{ + {"root", "left"}, {"root", "right"}, + {"left", "sink"}, {"right", "sink"}, + }, + want: []string{"root", "left", "right", "sink"}, + }, + { + name: "shared dependency at unequal depths", + keys: []string{"sink", "deep", "side", "middle", "root"}, + edges: [][2]string{ + {"root", "middle"}, {"middle", "deep"}, {"root", "side"}, + {"deep", "sink"}, {"side", "sink"}, + }, + want: []string{"root", "middle", "deep", "side", "sink"}, + }, + { + name: "newly ready small key precedes older large key", + keys: []string{"z-ready", "a-new", "b-ready"}, + edges: [][2]string{{"b-ready", "a-new"}}, + want: []string{"b-ready", "a-new", "z-ready"}, + }, + { + name: "duplicate edges count once", + keys: []string{"dependent", "prerequisite"}, + edges: [][2]string{ + {"prerequisite", "dependent"}, + {"prerequisite", "dependent"}, + }, + want: []string{"prerequisite", "dependent"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + keys, edges, indexes := dependencyGraph(test.keys, test.edges) + order, cycle := scheduleDependencies(keys, edges) + if len(cycle) != 0 { + t.Fatalf("unexpected cycle: %v", dependencyNames(cycle, indexes)) + } + if got := dependencyNames(order, indexes); !reflect.DeepEqual(got, test.want) { + t.Fatalf("schedule = %v, want %v", got, test.want) + } + }) + } +} + +func TestScheduleDependenciesUsesTupleKeys(t *testing.T) { + keys := []dependencyKey{ + {"a", "z"}, + {"a"}, + {"a", "aa"}, + {"A"}, + } + order, cycle := scheduleDependencies(keys, nil) + if len(cycle) != 0 { + t.Fatalf("unexpected cycle: %v", cycle) + } + want := []int{3, 1, 2, 0} + if !reflect.DeepEqual(order, want) { + t.Fatalf("schedule = %v, want %v", order, want) + } +} + +func TestScheduleDependenciesReportsActualCanonicalCycles(t *testing.T) { + tests := []struct { + name string + keys []string + edges [][2]string + want []string + }{ + { + name: "self cycle", + keys: []string{"self"}, + edges: [][2]string{{"self", "self"}}, + want: []string{"self", "self"}, + }, + { + name: "multi-node cycle excludes blocked dependent", + keys: []string{"blocked", "cycle-b", "cycle-a"}, + edges: [][2]string{ + {"cycle-a", "cycle-b"}, + {"cycle-b", "cycle-a"}, + {"cycle-b", "blocked"}, + }, + want: []string{"cycle-a", "cycle-b", "cycle-a"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + keys, edges, indexes := dependencyGraph(test.keys, test.edges) + order, cycle := scheduleDependencies(keys, edges) + if len(order) != 0 { + t.Fatalf("unexpected schedule: %v", dependencyNames(order, indexes)) + } + if got := dependencyNames(cycle, indexes); !reflect.DeepEqual(got, test.want) { + t.Fatalf("cycle = %v, want %v", got, test.want) + } + }) + } +} + +func TestScheduleDependenciesIsStableAcrossInsertionPermutations(t *testing.T) { + edges := [][2]string{ + {"base", "left"}, {"base", "right"}, + {"left", "sink"}, {"right", "sink"}, + } + permutations := [][]string{ + {"sink", "right", "base", "left"}, + {"left", "base", "sink", "right"}, + {"right", "left", "base", "sink"}, + } + for i, permutation := range permutations { + keys, permutedEdges, indexes := dependencyGraph(permutation, edges) + if i%2 != 0 { + reverseDependencyEdges(permutedEdges) + } + order, cycle := scheduleDependencies(keys, permutedEdges) + if len(cycle) != 0 { + t.Fatalf("permutation %d returned cycle %v", i, dependencyNames(cycle, indexes)) + } + if got, want := dependencyNames(order, indexes), []string{"base", "left", "right", "sink"}; !reflect.DeepEqual(got, want) { + t.Fatalf("permutation %d schedule = %v, want %v", i, got, want) + } + } + + cycleEdges := [][2]string{{"b", "a"}, {"a", "b"}, {"b", "blocked"}} + cyclePermutations := [][]string{{"blocked", "b", "a"}, {"a", "blocked", "b"}, {"b", "a", "blocked"}} + for i, permutation := range cyclePermutations { + keys, permutedEdges, indexes := dependencyGraph(permutation, cycleEdges) + if i%2 != 0 { + reverseDependencyEdges(permutedEdges) + } + order, cycle := scheduleDependencies(keys, permutedEdges) + if len(order) != 0 { + t.Fatalf("cycle permutation %d returned schedule %v", i, dependencyNames(order, indexes)) + } + if got, want := dependencyNames(cycle, indexes), []string{"a", "b", "a"}; !reflect.DeepEqual(got, want) { + t.Fatalf("cycle permutation %d returned %v, want %v", i, got, want) + } + } +} + +func dependencyGraph(nodeNames []string, namedEdges [][2]string) ([]dependencyKey, []dependencyEdge, map[int]string) { + keys := make([]dependencyKey, len(nodeNames)) + indexes := make(map[string]int, len(nodeNames)) + for i, name := range nodeNames { + keys[i] = dependencyKey{name} + indexes[name] = i + } + edges := make([]dependencyEdge, len(namedEdges)) + for i, edge := range namedEdges { + edges[i] = dependencyEdge{prerequisite: indexes[edge[0]], dependent: indexes[edge[1]]} + } + byIndex := make(map[int]string, len(nodeNames)) + for name, index := range indexes { + byIndex[index] = name + } + return keys, edges, byIndex +} + +func dependencyNames(nodes []int, names map[int]string) []string { + result := make([]string, len(nodes)) + for i, node := range nodes { + result[i] = names[node] + } + return result +} + +func reverseDependencyEdges(edges []dependencyEdge) { + for i, j := 0, len(edges)-1; i < j; i, j = i+1, j-1 { + edges[i], edges[j] = edges[j], edges[i] + } +}