Add deterministic dependency scheduler

This commit is contained in:
2026-10-19 15:11:28 +00:00
parent aa93aeda3f
commit 167fcd475f
2 changed files with 415 additions and 0 deletions

View File

@@ -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
}