Skip to content

Commit

Permalink
internal/core: calculate elementary cycles
Browse files Browse the repository at this point in the history
Within each Strongly Connected Component, we want to calculate the
cycles. Each node within a component (of two or more nodes) can be
involved in one or more cycles.

Signed-off-by: Matthew Sackman <[email protected]>
Change-Id: I4d1d4e527ab4abe49ac2dabc1d807488fe88c554
Reviewed-on: https://review.gerrithub.io/c/cue-lang/cue/+/1202378
TryBot-Result: CUEcueckoo <[email protected]>
Unity-Result: CUE porcuepine <[email protected]>
Reviewed-by: Marcel van Lohuizen <[email protected]>
  • Loading branch information
cuematthew committed Nov 5, 2024
1 parent 5855903 commit 9ddb0ec
Show file tree
Hide file tree
Showing 4 changed files with 289 additions and 1 deletion.
153 changes: 153 additions & 0 deletions internal/core/toposort/cycles.go
Original file line number Diff line number Diff line change
@@ -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]
}
115 changes: 115 additions & 0 deletions internal/core/toposort/cycles_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
})
}
}
5 changes: 4 additions & 1 deletion internal/core/toposort/graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
17 changes: 17 additions & 0 deletions internal/core/toposort/graph_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 9ddb0ec

Please sign in to comment.