diff --git a/internal/core/toposort/cycles.go b/internal/core/toposort/cycles.go new file mode 100644 index 00000000000..cd9b939bdd9 --- /dev/null +++ b/internal/core/toposort/cycles.go @@ -0,0 +1,153 @@ +// Copyright 2024 CUE Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package toposort + +import "slices" + +type ecNodeState struct { + visitedIncoming []*ecNodeState + blocked bool +} + +func (ecNode *ecNodeState) excluded() bool { + return ecNode == nil +} + +type ecFinderState struct { + cycles []*Cycle + stack []*Node +} + +type Cycle struct { + Nodes Nodes +} + +func (cycle *Cycle) RotateToStartAt(start *Node) { + nodes := cycle.Nodes + if start == nodes[0] { + return + } + for i, node := range nodes { + if start == node { + prefix := slices.Clone(nodes[:i]) + copy(nodes, nodes[i:]) + copy(nodes[len(nodes)-i:], prefix) + break + } + } +} + +// Calculate the Elementary Cycles (EC) within the current Strongly +// Connected Component (SCC). +// +// If the component contains no cycles (by definition, this means the +// component contains only a single node), then the slice returned +// will be empty. +// +// In general: +// +// 1. If a component contains two or more nodes then it contains at +// least one cycle. +// 2. A single node can be involved in many cycles. +// 3. This method finds all cycles within a component, but does not +// include cycles that are merely rotations of each +// other. I.e. every cycle is unique, ignoring rotations. +// 4. The cycles returned are unsorted: each cycle is itself in no +// particular rotation, and the complete slice of cycles is +// similarly unsorted. +// +// The complexity of this algorithm is O((n+e)*c) where +// - n: number of nodes in the SCC +// - e: number of edges between the nodes in the SCC +// - c: number of cycles discovered +// +// Donald B Johnson: Finding All the Elementary Circuits of a Directed +// Graph. SIAM Journal on Computing. Volumne 4, Nr. 1 (1975), +// pp. 77-84. +func (scc *StronglyConnectedComponent) ElementaryCycles() []*Cycle { + nodes := scc.Nodes + nodeStates := make([]ecNodeState, len(nodes)) + for i, node := range nodes { + node.ecNodeState = &nodeStates[i] + } + + ec := &ecFinderState{} + for i, node := range nodes { + ec.findCycles(node, node) + ec.unblockAll(nodes[i+1:]) + node.ecNodeState = nil + } + + return ec.cycles +} + +func (ec *ecFinderState) findCycles(origin, cur *Node) bool { + stackLen := len(ec.stack) + ec.stack = append(ec.stack, cur) + + curEc := cur.ecNodeState + curEc.blocked = true + + cycleFound := false + for _, next := range cur.Outgoing { + if next.ecNodeState.excluded() { + continue + } + if next == origin { // found cycle + ec.cycles = append(ec.cycles, &Cycle{Nodes: slices.Clone(ec.stack)}) + cycleFound = true + } else if !next.ecNodeState.blocked { + if ec.findCycles(origin, next) { + cycleFound = true + } + } + } + + if cycleFound { + ec.unblock(curEc) + } else { + for _, next := range cur.Outgoing { + if next.ecNodeState.excluded() { + continue + } + nextEc := next.ecNodeState + nextEc.visitedIncoming = append(nextEc.visitedIncoming, curEc) + } + } + + if len(ec.stack) != stackLen+1 { + panic("stack is unexpected height!") + } + ec.stack = ec.stack[:stackLen] + return cycleFound +} + +func (ec *ecFinderState) unblockAll(nodes Nodes) { + for _, node := range nodes { + nodeEc := node.ecNodeState + nodeEc.blocked = false + nodeEc.visitedIncoming = nodeEc.visitedIncoming[:0] + } +} + +func (ec *ecFinderState) unblock(nodeEc *ecNodeState) { + nodeEc.blocked = false + for _, previousEc := range nodeEc.visitedIncoming { + if previousEc.blocked { + ec.unblock(previousEc) + } + } + nodeEc.visitedIncoming = nodeEc.visitedIncoming[:0] +} diff --git a/internal/core/toposort/cycles_test.go b/internal/core/toposort/cycles_test.go new file mode 100644 index 00000000000..dd900b580c9 --- /dev/null +++ b/internal/core/toposort/cycles_test.go @@ -0,0 +1,115 @@ +// Copyright 2024 CUE Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package toposort_test + +import ( + "slices" + "testing" + + "cuelang.org/go/internal/core/adt" + "cuelang.org/go/internal/core/runtime" + "cuelang.org/go/internal/core/toposort" +) + +func TestElementaryCycles(t *testing.T) { + type TestCase struct { + name string + inputs [][]string + expected [][]string + } + + a, b, c, d, e, f, g := "a", "b", "c", "d", "e", "f", "g" + + testCases := []TestCase{ + { + name: "no cycles", + inputs: [][]string{{a, b, c}}, + expected: [][]string{}, + }, + { + name: "cycle of 2", + inputs: [][]string{{a, b}, {b, a}}, + expected: [][]string{{a, b}}, + }, + { + name: "cycle of 3", + inputs: [][]string{{a, b}, {b, c}, {c, a}}, + expected: [][]string{{a, b, c}}, + }, + { + name: "cycle of 3 and 4", + inputs: [][]string{{a, b, c, d}, {c, a}, {d, b}}, + expected: [][]string{{a, b, c}, {b, c, d}}, + }, + { + name: "unlinked cycles", + inputs: [][]string{{a, b, c, d}, {d, b}, {e, f, g}, {g, e}}, + expected: [][]string{{b, c, d}, {e, f, g}}, + }, + { + name: "fully connected 4", + inputs: [][]string{ + {a, b, c, d}, {d, c, b, a}, {b, d, a, c}, {c, a, d, b}, + }, + expected: [][]string{ + {a, b}, {a, b, c}, {a, b, c, d}, {a, b, d}, {a, b, d, c}, + {a, c}, {a, c, b}, {a, c, b, d}, {a, c, d}, {a, c, d, b}, + {a, d}, {a, d, b}, {a, d, b, c}, {a, d, c}, {a, d, c, b}, + {b, c}, {b, c, d}, + {b, d}, {b, d, c}, + {c, d}, + }, + }, + { + name: "nested cycles", + inputs: [][]string{{b, c}, {e, c, b, d}, {d, f, a, e}, {a, f}}, + expected: [][]string{{a, e, c, b, d, f}, {a, f}, {b, c}}, + }, + } + + index := runtime.New() + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + testAllPermutations(t, index, tc.inputs, + func(t *testing.T, perm [][]adt.Feature, graph *toposort.Graph) { + var cyclesNames [][]string + for _, scc := range graph.StronglyConnectedComponents() { + for _, cycle := range scc.ElementaryCycles() { + fs := cycle.Nodes.Features() + names := make([]string, len(fs)) + for j, f := range fs { + names[j] = f.StringValue(index) + } + cyclesNames = append(cyclesNames, names) + } + } + for _, cycle := range cyclesNames { + rotateToStartAt(cycle, slices.Min(cycle)) + } + slices.SortFunc(cyclesNames, compareStringses) + + if !slices.EqualFunc(cyclesNames, tc.expected, slices.Equal) { + t.Fatalf(` +For permutation: %v + Expected: %v + Got: %v`, + permutationNames(index, perm), + tc.expected, cyclesNames) + } + }) + }) + } +} diff --git a/internal/core/toposort/graph.go b/internal/core/toposort/graph.go index 88668c2761c..1faaadc5d27 100644 --- a/internal/core/toposort/graph.go +++ b/internal/core/toposort/graph.go @@ -33,7 +33,10 @@ type Node struct { // temporary state for calculating the Strongly Connected // Components of a graph. sccNodeState *sccNodeState - position int + // temporary state for calculating the Elementary Cycles of a + // graph. + ecNodeState *ecNodeState + position int } func (n *Node) IsSorted() bool { diff --git a/internal/core/toposort/graph_test.go b/internal/core/toposort/graph_test.go index 14db33a61a2..5f731c0f0cd 100644 --- a/internal/core/toposort/graph_test.go +++ b/internal/core/toposort/graph_test.go @@ -47,6 +47,23 @@ func compareStringses(a, b []string) int { return cmp.Compare(len(a), len(b)) } +// Consider that names are nodes in a cycle, we want to rotate the +// slice so that it starts at the given node name. This modifies the +// names slice in-place. +func rotateToStartAt(names []string, start string) { + if start == names[0] { + return + } + for i, node := range names { + if start == node { + prefix := slices.Clone(names[:i]) + copy(names, names[i:]) + copy(names[len(names)-i:], prefix) + break + } + } +} + func allPermutations(featureses [][]adt.Feature) [][][]adt.Feature { nonNilIdx := -1 var results [][][]adt.Feature