diff --git a/autolayout.go b/autolayout.go index c42e4cd..1b4f681 100644 --- a/autolayout.go +++ b/autolayout.go @@ -5,7 +5,6 @@ import ( "github.com/nulab/autog/graph" ig "github.com/nulab/autog/internal/graph" - "github.com/nulab/autog/internal/graph/connected" imonitor "github.com/nulab/autog/internal/monitor" "github.com/nulab/autog/internal/processor" "github.com/nulab/autog/internal/processor/postprocessor" @@ -55,7 +54,7 @@ func Layout(source graph.Source, opts ...Option) graph.Layout { shift := 0.0 // process each connected components and collect results into the same layout output - for _, g := range connected.Components(G) { + for _, g := range G.ConnectedComponents() { if len(g.Nodes) == 0 { panic("autog: connected sub-graph node set is empty: this might be a bug") } diff --git a/autolayout_options.go b/autolayout_options.go index 0b7c229..af59851 100644 --- a/autolayout_options.go +++ b/autolayout_options.go @@ -36,6 +36,7 @@ var defaultOptions = options{ NetworkSimplexThoroughness: 28, NetworkSimplexMaxIterFactor: 0, NetworkSimplexBalance: graph.OptionNsBalanceV, + VirtualNodeFixedSize: 0.0, WMedianMaxIter: 24, NetworkSimplexAuxiliaryGraphWeightFactor: 4, LayerSpacing: 150.0, diff --git a/autolayout_options_funcs.go b/autolayout_options_funcs.go index 08c9bb2..eaeddd7 100644 --- a/autolayout_options_funcs.go +++ b/autolayout_options_funcs.go @@ -50,6 +50,12 @@ func WithNodeFixedSize(w, h float64) Option { } } +func WithVirtualNodeFixedSize(n float64) Option { + return func(o *options) { + o.params.VirtualNodeFixedSize = n + } +} + func WithBrandesKoepfLayout(i int) Option { return func(o *options) { o.params.BrandesKoepfLayout = i diff --git a/autolayout_options_test.go b/autolayout_options_test.go index d1ccfdc..f501b33 100644 --- a/autolayout_options_test.go +++ b/autolayout_options_test.go @@ -16,6 +16,7 @@ func TestOptions(t *testing.T) { // set some random options that are different from the defaults opts := testOptions( WithCycleBreaking(CycleBreakingDepthFirst), + WithLayering(LayeringLongestPath), WithOrdering(OrderingNoop), WithPositioning(PositioningVAlign), WithEdgeRouting(EdgeRoutingStraight), @@ -25,10 +26,12 @@ func TestOptions(t *testing.T) { WithBrandesKoepfLayout(2), WithNodeFixedSize(100.0, 100.0), WithNodeSize(map[string]graph.Size{"N1": {W: 20, H: 20}}), + WithVirtualNodeFixedSize(50.0), WithNonDeterministicGreedyCycleBreaker(), ) assert.Equal(t, phase1.DepthFirst, opts.p1) + assert.Equal(t, phase2.LongestPath, opts.p2) assert.Equal(t, phase3.NoOrdering, opts.p3) assert.Equal(t, phase4.VerticalAlign, opts.p4) assert.Equal(t, phase5.Straight, opts.p5) @@ -40,6 +43,7 @@ func TestOptions(t *testing.T) { assert.Nil(t, opts.monitor) assert.NotNil(t, opts.params.NodeFixedSizeFunc) assert.NotNil(t, opts.params.NodeSizeFunc) + assert.Equal(t, 50.0, opts.params.VirtualNodeFixedSize) assert.True(t, opts.params.GreedyCycleBreakerRandomNodeChoice) assert.Equal(t, CycleBreakingGreedy, phase1.Greedy) diff --git a/autolayout_test.go b/autolayout_test.go new file mode 100644 index 0000000..1984768 --- /dev/null +++ b/autolayout_test.go @@ -0,0 +1,392 @@ +package autog + +import ( + "fmt" + "testing" + + "github.com/nulab/autog/graph" + ig "github.com/nulab/autog/internal/graph" + imonitor "github.com/nulab/autog/internal/monitor" + "github.com/stretchr/testify/assert" +) + +var dotAbstract = [][]string{ + {"S24", "27"}, + {"S24", "25"}, + {"S1", "10"}, + {"S1", "2"}, + {"S35", "36"}, + {"S35", "43"}, + {"S30", "31"}, + {"S30", "33"}, + {"9", "42"}, + {"9", "T1"}, + {"25", "T1"}, + {"25", "26"}, + {"27", "T24"}, + {"2", "3"}, + {"2", "16"}, + {"2", "17"}, + {"2", "T1"}, + {"2", "18"}, + {"10", "11"}, + {"10", "14"}, + {"10", "T1"}, + {"10", "13"}, + {"10", "12"}, + {"31", "T1"}, + {"31", "32"}, + {"33", "T30"}, + {"33", "34"}, + {"42", "4"}, + {"26", "4"}, + {"3", "4"}, + {"16", "15"}, + {"17", "19"}, + {"18", "29"}, + {"11", "4"}, + {"14", "15"}, + {"37", "39"}, + {"37", "41"}, + {"37", "38"}, + {"37", "40"}, + {"13", "19"}, + {"12", "29"}, + {"43", "38"}, + {"43", "40"}, + {"36", "19"}, + {"32", "23"}, + {"34", "29"}, + {"39", "15"}, + {"41", "29"}, + {"38", "4"}, + {"40", "19"}, + {"4", "5"}, + {"19", "21"}, + {"19", "20"}, + {"19", "28"}, + {"5", "6"}, + {"5", "T35"}, + {"5", "23"}, + {"21", "22"}, + {"20", "15"}, + {"28", "29"}, + {"6", "7"}, + {"15", "T1"}, + {"22", "23"}, + {"22", "T35"}, + {"29", "T30"}, + {"7", "T8"}, + {"23", "T24"}, + {"23", "T1"}, +} + +func TestLayoutBugfix(t *testing.T) { + t.Run("output layout empty nodes", func(t *testing.T) { + src := graph.EdgeSlice([][]string{ + {"N1", "N2"}, + {"N2", "N3"}, + {"N1", "N3"}, + }) + layout := Layout( + src, + WithPositioning(PositioningVAlign), + WithEdgeRouting(EdgeRoutingNoop), + ) + assert.Len(t, layout.Nodes, 3) + assert.Len(t, layout.Edges, 3) + }) + + t.Run("self-loop", func(t *testing.T) { + t.Run("program halts", func(t *testing.T) { + src := graph.EdgeSlice([][]string{ + {"a", "b"}, + {"b", "c"}, + {"b", "b"}, + {"a", "d"}, + }) + assert.NotPanics(t, func() { _ = Layout(src) }) + }) + t.Run("successful with single node", func(t *testing.T) { + src := graph.EdgeSlice([][]string{ + {"a", "a"}, + }) + assert.NotPanics(t, func() { _ = Layout(src) }) + }) + }) + + t.Run("greedy cycle breaker fails to break cycles", func(t *testing.T) { + g := graph.EdgeSlice([][]string{ + {"N1", "N4"}, + {"N1", "N8"}, + {"N2", "N5"}, + {"N2", "N8"}, + {"N3", "N8"}, + {"N6", "N3"}, + {"N8", "N1"}, + {"N8", "N2"}, + {"N8", "N7"}, + {"N8", "N15"}, + {"N8", "N16"}, + {"N9", "N10"}, + {"N10", "N11"}, + {"N12", "N13"}, + {"N13", "N14"}, + {"N15", "N1"}, + {"N15", "N9"}, + {"N15", "N10"}, + {"N16", "N2"}, + {"N16", "N12"}, + {"N16", "N13"}, + }) + assert.NotPanics(t, func() { + _ = Layout( + g, + WithNonDeterministicGreedyCycleBreaker(), + ) + }) + }) + + t.Run("wmedian", func(t *testing.T) { + t.Run("identical edge segfault in cross counting", func(t *testing.T) { + src := graph.EdgeSlice([][]string{ + {"gql", "acc"}, + {"gql", "dia"}, + {"gql", "edt"}, + {"gql", "fld"}, + {"gql", "itg"}, + {"gql", "ntf"}, + {"gql", "org"}, + {"gql", "sub"}, + {"gql", "spt"}, + {"gql", "tmp"}, + {"acc", "lgc"}, + {"acc", "sub"}, + {"fld", "acc"}, + {"fld", "dia"}, + {"fld", "org"}, + {"fld", "sub"}, + {"dia", "acc"}, + {"dia", "fld"}, + {"dia", "lgc"}, + {"dia", "org"}, + {"dia", "sub"}, + }) + assert.NotPanics(t, func() { _ = Layout(src) }) + }) + + t.Run("wrong initialization of flat edges", func(t *testing.T) { + src := graph.EdgeSlice([][]string{ + {"gql", "acc"}, + {"gql", "dia"}, + {"gql", "edt"}, + {"gql", "fld"}, + {"gql", "itg"}, + {"gql", "ntf"}, + {"gql", "org"}, + {"gql", "sub"}, + {"gql", "spt"}, + {"gql", "tmp"}, + {"acc", "lgc"}, + {"acc", "sub"}, + {"dia", "acc"}, + {"dia", "fld"}, + {"dia", "lgc"}, + {"dia", "org"}, + {"dia", "sub"}, + {"fld", "acc"}, + {"fld", "dia"}, + {"fld", "org"}, + {"fld", "sub"}, + }) + assert.NotPanics(t, func() { _ = Layout(src) }) + }) + + t.Run("wrong handling of fixed positions in wmedian", func(t *testing.T) { + c := make(chan any, 1) + assert.NotPanics(t, func() { + _ = Layout( + graph.EdgeSlice(dotAbstract), + WithPositioning(PositioningNoop), + WithEdgeRouting(EdgeRoutingNoop), + WithMonitor(imonitor.NewFilteredChan(c, imonitor.MatchAll(3, "gvdot", "crossings"))), + ) + }) + + assert.Equal(t, 46, <-c) + }) + }) + + t.Run("network simplex positioner no panic", func(t *testing.T) { + src := graph.EdgeSlice(dotAbstract) + assert.NotPanics(t, func() { + _ = Layout( + src, + WithPositioning(PositioningNetworkSimplex), + WithEdgeRouting(EdgeRoutingNoop), + WithNodeFixedSize(100, 100), + ) + }) + }) + + t.Run("b&k no overlaps", func(t *testing.T) { + g := &ig.DGraph{} + graph.EdgeSlice([][]string{ + {"a", "b"}, + {"b", "c"}, + {"a", "f"}, + {"f", "g"}, + {"a", "u"}, + {"f", "c"}, + {"c", "k"}, + {"f", "k"}, + }).Populate(g) + _ = Layout(g, + WithPositioning(PositioningBrandesKoepf), + WithNodeFixedSize(130, 60), + ) + + overlaps := 0 + for _, l := range g.Layers { + for j := 1; j < l.Len(); j++ { + cur := l.Nodes[j] + prv := l.Nodes[j-1] + + if prv.X+prv.W > cur.X { + if overlaps >= 0 { + // note: this isn't a strict inequality because virtual nodes have size 0x0 + assert.Truef(t, prv.X+prv.W <= cur.X, "%s(X:%.2f) overlaps %s(X+W:%.2f)", cur, cur.X, prv, prv.X+prv.W) + } else { + fmt.Printf("warning: overlap between nodes %v and %v within tolerance\n", cur, prv) + } + overlaps++ + } + } + } + }) + + t.Run("sink coloring program hangs", func(t *testing.T) { + src := graph.EdgeSlice([][]string{ + {"N1", "N2"}, + {"N3", "N1"}, + {"N2", "N3"}, + {"Nh", "N1"}, + {"Nk", "N1"}, + {"Na", "N2"}, + {"Na", "N3"}, + {"N2", "Nd"}, + }) + assert.NotPanics(t, func() { _ = Layout(src, WithPositioning(PositioningSinkColoring)) }) + }) +} + +func TestOutputVirtualNodes(t *testing.T) { + src := graph.EdgeSlice([][]string{ + {"N1", "N2"}, + {"N2", "N3"}, + {"N1", "N3"}, + }) + t.Run("keep virtual nodes", func(t *testing.T) { + g := &ig.DGraph{} + src.Populate(g) + layout := Layout( + g, + WithPositioning(PositioningVAlign), + WithEdgeRouting(EdgeRoutingNoop), + WithOutputVirtualNodes(true), + ) + assert.Len(t, layout.Nodes, 4) + assert.Len(t, layout.Edges, 3) + }) + + t.Run("clip output nodes", func(t *testing.T) { + g := &ig.DGraph{} + src.Populate(g) + layout := Layout( + g, + WithPositioning(PositioningVAlign), + WithEdgeRouting(EdgeRoutingNoop), + WithOutputVirtualNodes(false), + ) + assert.Equal(t, 3, len(layout.Nodes)) + assert.Equal(t, 3, cap(layout.Nodes)) + assert.Equal(t, 4, len(g.Nodes)) + + assert.Len(t, layout.Edges, 3) + }) +} + +func TestNoRegression(t *testing.T) { + t.Run("ELK", func(t *testing.T) { + var lib_decg_DECGPi = [][]string{ + {"N2", "N8"}, + {"N2", "N13"}, + {"N2", "N15"}, + {"N2", "N4"}, + {"N3", "N1"}, + {"N4", "N3"}, + {"N5", "N16"}, + {"N5", "N18"}, + {"N6", "N8"}, + {"N6", "N18"}, + {"N7", "N6"}, + {"N8", "N5"}, + {"N8", "N9"}, + {"N9", "N6"}, + {"N9", "N7"}, + {"N10", "N14"}, + {"N10", "N19"}, + {"N11", "N10"}, + {"N12", "N10"}, + {"N12", "N11"}, + {"N13", "N14"}, + {"N14", "N17"}, + {"N14", "N12"}, + {"N15", "N13"}, + {"N16", "N4"}, + {"N17", "N16"}, + {"N17", "N19"}, + {"N18", "N5"}, + {"N19", "N17"}, + } + + var pn_brockackerman_BrockAckerman = [][]string{ + {"N1", "N2"}, + {"N2", "N9"}, + {"N4", "N10"}, + {"N4", "N15"}, + {"N5", "N6"}, + {"N6", "N14"}, + {"N8", "N1"}, + {"N8", "N3"}, + {"N9", "N12"}, + {"N10", "N11"}, + {"N11", "N12"}, + {"N12", "N8"}, + {"N13", "N5"}, + {"N13", "N7"}, + {"N14", "N16"}, + {"N15", "N16"}, + {"N16", "N13"}, + } + var elkTestGraphs = []struct { + name string + adj [][]string + }{ + {"lib_decg_DECGPi", lib_decg_DECGPi}, + {"pn_brockackerman_BrockAckerman", pn_brockackerman_BrockAckerman}, + } + + for _, testcase := range elkTestGraphs { + t.Run(testcase.name, func(t *testing.T) { + assert.NotPanics(t, func() { Layout(graph.EdgeSlice(testcase.adj)) }) + }) + } + }) + + t.Run("Dot abstract with default options", func(t *testing.T) { + assert.NotPanics(t, func() { + Layout(graph.EdgeSlice(dotAbstract)) + }) + }) +} diff --git a/graph/source.go b/graph/source.go index 5b8c472..07e0b74 100644 --- a/graph/source.go +++ b/graph/source.go @@ -2,8 +2,7 @@ package graph import ig "github.com/nulab/autog/internal/graph" -// Source represent the source of graph data. It hides the implementation details of the internal DGraph struct -// and allows only this module to provide implementations. -type Source interface { - Populate(*ig.DGraph) -} +type ( + Source = ig.Source + EdgeSlice = ig.EdgeSlice +) diff --git a/internal/collectors/deque_test.go b/internal/collectors/deque_test.go index b1bc8d6..c230693 100644 --- a/internal/collectors/deque_test.go +++ b/internal/collectors/deque_test.go @@ -8,6 +8,7 @@ import ( func TestDeque(t *testing.T) { deq := NewDeque[string](10) + // w2 w1 u p v w3 deq.PushFront("p") deq.PushFront("u") deq.PushBack("v") @@ -15,9 +16,11 @@ func TestDeque(t *testing.T) { deq.PushFront("w2") deq.PushBack("w3") assert.Equal(t, 6, deq.Len()) - assert.Equal(t, 6, deq.f) // 10-4 - assert.Equal(t, 11, deq.b) // 9+2 + assert.Equal(t, 6, deq.Front()) // 10-4 + assert.Equal(t, 11, deq.Back()) // 9+2 assert.Equal(t, "v", deq.data[10]) assert.Equal(t, "w2", deq.PopFront()) assert.Equal(t, "w3", deq.PopBack()) + assert.Equal(t, "w1", deq.PeekFront(1)) + assert.Equal(t, "v", deq.PeekBack(1)) } diff --git a/internal/collectors/mat.go b/internal/collectors/mat.go index d9bd0ec..2e6f81f 100644 --- a/internal/collectors/mat.go +++ b/internal/collectors/mat.go @@ -2,7 +2,11 @@ package collectors type Mat[T any] [][]T +// NewMat creates a new n-by-n square matrix. All entries are T's zero value. A negative n is treated as 0. func NewMat[T any](n int) Mat[T] { + if n < 0 { + n = 0 + } m := make([][]T, n) for i := range m { m[i] = make([]T, n) diff --git a/internal/collectors/mat_test.go b/internal/collectors/mat_test.go new file mode 100644 index 0000000..1a1b82b --- /dev/null +++ b/internal/collectors/mat_test.go @@ -0,0 +1,20 @@ +package collectors + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMat(t *testing.T) { + var m Mat[int] + + m = NewMat[int](0) + assert.Len(t, m, 0) + + m = NewMat[int](3) + assert.Len(t, m, 3) + + m = NewMat[int](-1) + assert.Len(t, m, 0) +} diff --git a/internal/collectors/stack.go b/internal/collectors/stack.go deleted file mode 100644 index 6ba8df9..0000000 --- a/internal/collectors/stack.go +++ /dev/null @@ -1,27 +0,0 @@ -package collectors - -type stack[T any] struct { - ts []T -} - -func NewStack[T any](n int) *stack[T] { - return &stack[T]{make([]T, 0, n)} -} - -func (s *stack[T]) Push(vs ...T) { - s.ts = append(s.ts, vs...) -} - -func (s *stack[T]) Pop() (t T) { - t = s.ts[s.Len()-1] - s.ts = s.ts[:s.Len()-1] - return -} - -func (s *stack[T]) Peek(n int) T { - return s.ts[s.Len()-n] -} - -func (s *stack[T]) Len() int { - return len(s.ts) -} diff --git a/internal/geom/point.go b/internal/geom/point.go index 568cefd..da22531 100644 --- a/internal/geom/point.go +++ b/internal/geom/point.go @@ -69,7 +69,7 @@ func norm(p P) P { return p } -func rotate(p P, theta float64) P { +func rotatep(p P, theta float64) P { cs, sn := math.Cos(theta), math.Sin(theta) return P{ X: p.X*cs - p.Y*sn, diff --git a/internal/geom/point_test.go b/internal/geom/point_test.go new file mode 100644 index 0000000..aead922 --- /dev/null +++ b/internal/geom/point_test.go @@ -0,0 +1,66 @@ +package geom + +import ( + "math" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPoint(t *testing.T) { + t.Run("svg", func(t *testing.T) { + p := P{10.8, 50.1} + assert.Equal(t, ``, p.SVG()) + }) + + t.Run("addp", func(t *testing.T) { + a := P{1, 2} + b := P{3, 4} + assert.Equal(t, P{4, 6}, addp(a, b)) + }) + + t.Run("subp", func(t *testing.T) { + a := P{5, 6} + b := P{3, 4} + assert.Equal(t, P{2, 2}, subp(a, b)) + }) + + t.Run("scalep", func(t *testing.T) { + p := P{2, 3} + assert.Equal(t, P{4, 6}, scalep(p, 2.0)) + }) + + t.Run("dotp", func(t *testing.T) { + a := P{1, 2} + b := P{3, 4} + assert.Equal(t, 11.0, dotp(a, b)) + }) + + t.Run("distp", func(t *testing.T) { + a := P{0, 0} + b := P{3, 4} + assert.Equal(t, 5.0, distp(a, b)) + }) + + t.Run("sqdistp", func(t *testing.T) { + a := P{0, 0} + b := P{3, 4} + assert.Equal(t, 25.0, sqdistp(a, b)) + }) + + t.Run("norm", func(t *testing.T) { + a := P{3, 4} + got := norm(a) + want := P{0.6, 0.8} + assert.InDelta(t, want.X, got.X, 1e-9) + assert.InDelta(t, want.Y, got.Y, 1e-9) + }) + + t.Run("rotatep", func(t *testing.T) { + p := P{1, 0} + got := rotatep(p, math.Pi/2) + want := P{0, 1} + assert.InDelta(t, want.X, got.X, 1e-9) + assert.InDelta(t, want.Y, got.Y, 1e-9) + }) +} diff --git a/internal/geom/rect.go b/internal/geom/rect.go index 2e7784b..c66a9be 100644 --- a/internal/geom/rect.go +++ b/internal/geom/rect.go @@ -15,7 +15,7 @@ func (r Rect) String() string { func (r Rect) SVG() string { width := r.BR.X - r.TL.X height := r.BR.Y - r.TL.Y - return fmt.Sprintf(``, r.TL.X, r.TL.Y, width, height) + return fmt.Sprintf(``, r.TL.X, r.TL.Y, width, height) } func (r Rect) Width() float64 { diff --git a/internal/geom/rect_test.go b/internal/geom/rect_test.go new file mode 100644 index 0000000..181b126 --- /dev/null +++ b/internal/geom/rect_test.go @@ -0,0 +1,15 @@ +package geom + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRect(t *testing.T) { + r := Rect{TL: P{10, 20}, BR: P{50, 30}} + assert.Equal(t, 40.0, r.Width()) + assert.Equal(t, 10.0, r.Height()) + assert.Equal(t, ``, r.SVG()) + +} diff --git a/internal/geom/spline_make.go b/internal/geom/spline_make.go index 290bf8a..14061fc 100644 --- a/internal/geom/spline_make.go +++ b/internal/geom/spline_make.go @@ -21,8 +21,8 @@ func MakeSpline(a, b P) ctrlp { theta2 := sign(-v.X) * math.Pi * (9.0 / 10.0) // rotate the vector - r := rotate(v, theta1) - q := rotate(v, theta2) + r := rotatep(v, theta1) + q := rotatep(v, theta2) // slide the control points along the rotated vector p1 := P{a.X + k*r.X, a.Y + k*r.Y} p2 := P{b.X + k*q.X, b.Y + k*q.Y} diff --git a/internal/geom/spline_make_test.go b/internal/geom/spline_make_test.go new file mode 100644 index 0000000..568bdd3 --- /dev/null +++ b/internal/geom/spline_make_test.go @@ -0,0 +1,55 @@ +package geom + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMakeSpline(t *testing.T) { + const delta = 1e-2 + + t.Run("vertically aligned", func(t *testing.T) { + a := P{20, 30} + b := P{20, 50} + s := MakeSpline(a, b) + assert.True(t, s.p0 == s.p1) + assert.True(t, s.p2 == s.p3) + }) + + t.Run("horizontal segment", func(t *testing.T) { + a := P{20, 10} + b := P{50, 10} + s := MakeSpline(a, b) + assert.Equal(t, s.p0, a) + assert.InDelta(t, 25.7, s.p1.X, delta) + assert.InDelta(t, 8.14, s.p1.Y, delta) + assert.InDelta(t, 44.3, s.p2.X, delta) + assert.InDelta(t, 8.14, s.p2.Y, delta) + assert.Equal(t, s.p3, b) + }) + + t.Run("negative slope", func(t *testing.T) { + a := P{50, 20} + b := P{20, 10} + s := MakeSpline(a, b) + assert.Equal(t, s.p0, a) + assert.InDelta(t, 44.91, s.p1.X, delta) + assert.InDelta(t, 16.24, s.p1.Y, delta) + assert.InDelta(t, 26.32, s.p2.X, delta) + assert.InDelta(t, 10.04, s.p2.Y, delta) + assert.Equal(t, s.p3, b) + }) + + t.Run("positive slope", func(t *testing.T) { + a := P{20, 10} + b := P{50, 20} + s := MakeSpline(a, b) + assert.Equal(t, s.p0, a) + assert.InDelta(t, 26.32, s.p1.X, delta) + assert.InDelta(t, 10.04, s.p1.Y, delta) + assert.InDelta(t, 44.91, s.p2.X, delta) + assert.InDelta(t, 16.24, s.p2.Y, delta) + assert.Equal(t, s.p3, b) + }) +} diff --git a/internal/graph/connected.go b/internal/graph/connected.go new file mode 100644 index 0000000..56c8b31 --- /dev/null +++ b/internal/graph/connected.go @@ -0,0 +1,41 @@ +package graph + +import ( + "maps" +) + +func (g *DGraph) ConnectedComponents() []*DGraph { + visitedN := make(NodeSet) + visitedE := make(EdgeSet) + walkDfs(g.Nodes[0], visitedN, visitedE) + + // if all nodes were visited at the first dfs + // then there is only one connected component and that is G itself + if len(visitedN) == len(g.Nodes) { + return []*DGraph{g} + } + + cnncmp := make([]*DGraph, 0, 2) // this has at least 2 connected components + cnncmp = append(cnncmp, &DGraph{Nodes: visitedN.Keys(), Edges: visitedE.Keys()}) + + for _, n := range g.Nodes { + if !visitedN[n] { + ns := make(NodeSet) + es := make(EdgeSet) + walkDfs(n, ns, es) + cnncmp = append(cnncmp, &DGraph{Nodes: ns.Keys(), Edges: es.Keys()}) + maps.Copy(visitedN, ns) + } + } + return cnncmp +} + +func walkDfs(n *Node, visitedN NodeSet, visitedE EdgeSet) { + visitedN[n] = true + n.VisitEdges(func(e *Edge) { + if !visitedE[e] { + visitedE[e] = true + walkDfs(e.ConnectedNode(n), visitedN, visitedE) + } + }) +} diff --git a/internal/graph/connected/connected.go b/internal/graph/connected/connected.go deleted file mode 100644 index e327820..0000000 --- a/internal/graph/connected/connected.go +++ /dev/null @@ -1,43 +0,0 @@ -package connected - -import ( - "maps" - - ig "github.com/nulab/autog/internal/graph" -) - -func Components(g *ig.DGraph) []*ig.DGraph { - visitedN := make(ig.NodeSet) - visitedE := make(ig.EdgeSet) - walkDfs(g.Nodes[0], visitedN, visitedE) - - // if all nodes were visited at the first dfs - // then there is only one connected component and that is G itself - if len(visitedN) == len(g.Nodes) { - return []*ig.DGraph{g} - } - - cnncmp := make([]*ig.DGraph, 0, 2) // this has at least 2 connected components - cnncmp = append(cnncmp, &ig.DGraph{Nodes: visitedN.Keys(), Edges: visitedE.Keys()}) - - for _, n := range g.Nodes { - if !visitedN[n] { - ns := make(ig.NodeSet) - es := make(ig.EdgeSet) - walkDfs(n, ns, es) - cnncmp = append(cnncmp, &ig.DGraph{Nodes: ns.Keys(), Edges: es.Keys()}) - maps.Copy(visitedN, ns) - } - } - return cnncmp -} - -func walkDfs(n *ig.Node, visitedN ig.NodeSet, visitedE ig.EdgeSet) { - visitedN[n] = true - n.VisitEdges(func(e *ig.Edge) { - if !visitedE[e] { - visitedE[e] = true - walkDfs(e.ConnectedNode(n), visitedN, visitedE) - } - }) -} diff --git a/internal/graph/connected/connected_test.go b/internal/graph/connected_test.go similarity index 75% rename from internal/graph/connected/connected_test.go rename to internal/graph/connected_test.go index 3c40f67..64ad9d1 100644 --- a/internal/graph/connected/connected_test.go +++ b/internal/graph/connected_test.go @@ -1,10 +1,8 @@ -package connected +package graph import ( "testing" - "github.com/nulab/autog/graph" - ig "github.com/nulab/autog/internal/graph" "github.com/stretchr/testify/assert" ) @@ -14,10 +12,10 @@ func TestComponents(t *testing.T) { {"a", "b"}, {"b", "c"}, } - g := &ig.DGraph{} - graph.EdgeSlice(es).Populate(g) + g := &DGraph{} + EdgeSlice(es).Populate(g) - comp := Components(g) + comp := g.ConnectedComponents() assert.Len(t, comp, 1) assert.True(t, comp[0] == g) }) @@ -28,10 +26,10 @@ func TestComponents(t *testing.T) { {"b", "c"}, {"f", "g"}, } - g := &ig.DGraph{} - graph.EdgeSlice(es).Populate(g) + g := &DGraph{} + EdgeSlice(es).Populate(g) - comp := Components(g) + comp := g.ConnectedComponents() assert.Len(t, comp, 2) assert.ElementsMatch(t, []string{"a", "b", "c"}, ids(comp[0].Nodes)) assert.ElementsMatch(t, []string{"f", "g"}, ids(comp[1].Nodes)) @@ -46,10 +44,10 @@ func TestComponents(t *testing.T) { {"l", "j"}, {"z", "f"}, } - g := &ig.DGraph{} - graph.EdgeSlice(es).Populate(g) + g := &DGraph{} + EdgeSlice(es).Populate(g) - comp := Components(g) + comp := g.ConnectedComponents() assert.Len(t, comp, 4) assert.ElementsMatch(t, []string{"a", "b", "c"}, ids(comp[0].Nodes)) assert.ElementsMatch(t, []string{"f", "g", "h", "i", "z"}, ids(comp[1].Nodes)) @@ -59,7 +57,7 @@ func TestComponents(t *testing.T) { } -func ids(ns []*ig.Node) (ids []string) { +func ids(ns []*Node) (ids []string) { for _, n := range ns { ids = append(ids, n.ID) } diff --git a/internal/graph/dgraph.go b/internal/graph/dgraph.go index 0ed9a92..ef3df27 100644 --- a/internal/graph/dgraph.go +++ b/internal/graph/dgraph.go @@ -2,7 +2,6 @@ package graph import ( "iter" - "strings" ) type DGraph struct { @@ -52,37 +51,14 @@ func (g *DGraph) Sinks() iter.Seq[*Node] { } } -func (g *DGraph) String() string { - bld := strings.Builder{} - for _, n := range g.Nodes { - bld.WriteString(n.ID) - bld.WriteRune('\n') - bld.WriteString("-IN:") - if len(n.In) == 0 { - bld.WriteRune('\t') - bld.WriteString("none") - bld.WriteRune('\n') - } - for _, e := range n.In { - bld.WriteRune('\t') - bld.WriteString(e.From.ID) - bld.WriteString(" -> ") - bld.WriteString(n.ID) - bld.WriteRune('\n') - } - bld.WriteString("-OUT:") - if len(n.Out) == 0 { - bld.WriteRune('\t') - bld.WriteString("none") - bld.WriteRune('\n') - } - for _, e := range n.Out { - bld.WriteRune('\t') - bld.WriteString(n.ID) - bld.WriteString(" -> ") - bld.WriteString(e.To.ID) - bld.WriteRune('\n') +func (g *DGraph) VirtualNodes() iter.Seq[*Node] { + return func(yield func(*Node) bool) { + for _, n := range g.Nodes { + if n.IsVirtual { + if !yield(n) { + return + } + } } } - return bld.String() } diff --git a/internal/graph/layer_test.go b/internal/graph/layer_test.go new file mode 100644 index 0000000..1591517 --- /dev/null +++ b/internal/graph/layer_test.go @@ -0,0 +1,7 @@ +package graph + +import "testing" + +func TestLayer(t *testing.T) { + +} diff --git a/internal/graph/params.go b/internal/graph/params.go index f4cc9c6..e50b18b 100644 --- a/internal/graph/params.go +++ b/internal/graph/params.go @@ -38,6 +38,9 @@ type Params struct { // ---- phase3 options --- + // Size of virtual nodes (NxN). Defaults to zero, i.e. virtual nodes are treated as points. + VirtualNodeFixedSize float64 + // Maximum number of iterations of the WMedian orderer. WMedianMaxIter uint diff --git a/internal/graph/source.go b/internal/graph/source.go new file mode 100644 index 0000000..736b7d6 --- /dev/null +++ b/internal/graph/source.go @@ -0,0 +1,7 @@ +package graph + +// Source represent the source of graph data. It hides the implementation details of the internal DGraph struct +// and allows only this module to provide implementations. +type Source interface { + Populate(*DGraph) +} diff --git a/graph/source_edgeslice.go b/internal/graph/source_edgeslice.go similarity index 68% rename from graph/source_edgeslice.go rename to internal/graph/source_edgeslice.go index 8b7d406..9c8f907 100644 --- a/graph/source_edgeslice.go +++ b/internal/graph/source_edgeslice.go @@ -1,17 +1,15 @@ package graph -import ig "github.com/nulab/autog/internal/graph" - // EdgeSlice is a graph Source. type EdgeSlice [][]string var _ Source = EdgeSlice{} -func (edges EdgeSlice) Populate(g *ig.DGraph) { - nodeMap := map[string]*ig.Node{} +func (edges EdgeSlice) Populate(g *DGraph) { + nodeMap := map[string]*Node{} - nodeList := []*ig.Node{} - edgeList := []*ig.Edge{} + nodeList := []*Node{} + edgeList := []*Edge{} for _, e := range edges { if len(e) != 2 { @@ -22,18 +20,18 @@ func (edges EdgeSlice) Populate(g *ig.DGraph) { sourceNode := nodeMap[sourceId] if sourceNode == nil { - sourceNode = &ig.Node{ID: sourceId} + sourceNode = &Node{ID: sourceId} nodeList = append(nodeList, sourceNode) nodeMap[sourceId] = sourceNode } targetNode := nodeMap[targetId] if targetNode == nil { - targetNode = &ig.Node{ID: targetId} + targetNode = &Node{ID: targetId} nodeList = append(nodeList, targetNode) nodeMap[targetId] = targetNode } - e := ig.NewEdge(sourceNode, targetNode, 1) // default to weight 1 + e := NewEdge(sourceNode, targetNode, 1) // default to weight 1 edgeList = append(edgeList, e) targetNode.In = append(targetNode.In, e) diff --git a/internal/ns/ns.go b/internal/ns/ns.go new file mode 100644 index 0000000..52e7156 --- /dev/null +++ b/internal/ns/ns.go @@ -0,0 +1,391 @@ +package ns + +import ( + "math" + "slices" + + "github.com/nulab/autog/internal/graph" +) + +type Processor struct { + lim graph.NodeIntMap // Gansner et al.: number from a root node in spanning tree postorder traversal + low graph.NodeIntMap // Gansner et al.: lowest postorder traversal number among nodes reachable from the input node +} + +// Exec implements a graph node layering algorithm, based on: +// - "Emden R. Gansner, Eleftherios Koutsofios, Stephen C. North, Kiem-Phong Vo, A technique for +// drawing directed graphs. Software Engineering 19(3), pp. 214-230, 1993." +// https://www.researchgate.net/publication/3187542_A_Technique_for_Drawing_Directed_Graphs +// - ELK Java code at https://github.com/eclipse/elk/blob/master/plugins/org.eclipse.elk.alg.layered/src/org/eclipse/elk/alg/layered/p2layers/NetworkSimplexLayerer.java +func (p *Processor) Exec(g *graph.DGraph, params graph.Params) { + p.lim = make(graph.NodeIntMap) + p.low = make(graph.NodeIntMap) + + p.feasibleTree(g) + + // ELK defines the max iterations as an arbitrary user value N times a fixed factor K times the sqroot of |V|. + // where |V| is the number of nodes in each connected component. + // N*K in ELK defaults to 28. + k1 := int(math.Sqrt(float64(len(g.Nodes)))) + if params.NetworkSimplexMaxIterFactor > 0 { + k1 = params.NetworkSimplexMaxIterFactor + } + maxitr := int(params.NetworkSimplexThoroughness) * k1 + + e := negCutValueTreeEdge(g.Edges) + i := 0 + for e != nil { + if i >= maxitr { + break + } + f := p.minSlackNonTreeEdge(g.Edges, e) + // todo: figure out why this could be nil + if f == nil { + break + } + p.exchange(e, f, g) + e = negCutValueTreeEdge(g.Edges) + i++ + } + normalize(g) + switch params.NetworkSimplexBalance { + case 1: + vbalance(g) + case 2: + p.hbalance(g) + } +} + +// returns the first tree edge with negative cut value; +// may return nil if there is no such edge, meaning the solution is optimal. +func negCutValueTreeEdge(edges []*graph.Edge) *graph.Edge { + for _, e := range edges { + if e.IsInSpanningTree && e.CutValue < 0 { + return e + } + } + return nil +} + +// Replace candidates are non-tree edges that go from e's head component to its tail component (original direction). +// The first candidate with minimum slack is chosen. +func (p *Processor) minSlackNonTreeEdge(edges []*graph.Edge, e *graph.Edge) *graph.Edge { + var minSlack = math.MaxInt + var replaceCandidate *graph.Edge + for _, f := range edges { + if f == e || f.IsInSpanningTree { + continue + } + if p.inHeadComponent(f.From, e) && !p.inHeadComponent(f.To, e) { + slack := slack(f) + if slack < minSlack { + minSlack = slack + replaceCandidate = f + } + } + } + return replaceCandidate // nil if not otherwise assigned +} + +// computes an initial feasible spanning tree; it's feasible if its edges are tight +func (p *Processor) feasibleTree(g *graph.DGraph) { + p.initLayers(g) + for { + treeNodes := tightTree(g.Nodes[0], graph.EdgeSet{}, graph.NodeSet{}) + if len(treeNodes) == len(g.Nodes) { + break + } + e := p.incidentNonTreeEdge(treeNodes) + // incident means that one of e's vertices belongs to the tree and one doesn't. + // here e's slack must be >= 0: since it points to a non-tree node, if the slack + // were 0 it would've been included in the tight tree. + // then, the layers of tree nodes are adjusted to make e's slack equal to zero. + // as the edge becomes tight, it will be included in the tree together with its non-tree vertex + // at the next iteration. + d := slack(e) + if treeNodes[e.To] { + d = -d + } + for n := range treeNodes { + n.Layer += d + } + } + + p.setStreeValues(g.Nodes[0]) + p.setCutValues(g) +} + +// Starting from the graph source nodes (no incoming edges), this method assigns an initial layer +// to each node n based on the maximum layer of nodes connected to them via incoming edges. +// This makes all directed edges point downward. +// Source nodes have no incoming edges, so are processed first. +func (p *Processor) initLayers(g *graph.DGraph) { + // initialize the count of incoming edges for all nodes + unseenInEdges := make(graph.NodeIntMap) + for _, n := range g.Nodes { + unseenInEdges[n] = n.Indeg() + } + + // sources have layer 0 + sources := slices.Collect(g.Sources()) + + for len(sources) > 0 { + n := sources[0] + sources = sources[1:] + + // given a directed edge e = (n,m) + // the target node m is assigned the layer of the source node plus delta + // this makes the edge tight by construction because slack(e) = 0 + for _, e := range n.Out { + m := e.To + m.Layer = max(m.Layer, n.Layer+e.Delta) + unseenInEdges[m]-- + if unseenInEdges[m] == 0 { + sources = append(sources, m) + } + } + } +} + +// Starting from the given node, this constructs a spanning tree with only tight edges (slack = 0). +// It returns visitedNodes which contains the nodes that belong to this spanning tree. +func tightTree(n *graph.Node, visitedEdges graph.EdgeSet, visitedNodes graph.NodeSet) graph.NodeSet { + visitedNodes[n] = true + n.VisitEdges(func(e *graph.Edge) { + if !visitedEdges[e] { + visitedEdges[e] = true + m := e.ConnectedNode(n) + if e.IsInSpanningTree { + tightTree(m, visitedEdges, visitedNodes) + } else if !visitedNodes[m] && slack(e) == 0 { + // checking that m hasn't been seen before ensures there are no loopbacks in this spanning tree + e.IsInSpanningTree = true + tightTree(m, visitedEdges, visitedNodes) + } + } + }) + return visitedNodes +} + +// This finds a "non-tree edge incident on the tree with min amount of slack". +// Incident means that only one of the edge's vertices belongs to the spanning tree. +func (p *Processor) incidentNonTreeEdge(treeNodes graph.NodeSet) *graph.Edge { + var minSlack = math.MaxInt + var candidate *graph.Edge + // todo: range on map non-deterministic + for n := range treeNodes { + n.VisitEdges(func(e *graph.Edge) { + if e.SelfLoops() { + return + } + if e.IsInSpanningTree || treeNodes[e.ConnectedNode(n)] { + return + } + slack := slack(e) + if slack < minSlack { + minSlack = slack + candidate = e + } + }) + } + if candidate == nil { + panic("network simplex: did not find adjacent non-tree edge with min slack: make sure the graph is connected") + } + return candidate +} + +func (p *Processor) inHeadComponent(n *graph.Node, e *graph.Edge) bool { + if !e.IsInSpanningTree { + panic("network simplex: breaking tree around non-tree edge") + } + u, v := e.From, e.To + + // the following boolean logic follows Graphviz's paper: + // "For example, if e = (u,v) is a tree edge and vroot is in the head component of the edge (i.e., lim(u) < lim(v)), + // then a node w is in the tail component of e if and only if low(u) ≤ lim(w) ≤ lim(u)." + if p.lim[u] < p.lim[v] { + if p.low[u] <= p.lim[n] && p.lim[n] <= p.lim[u] { + // this inequality means that n is in the subtree rooted in u; + // because of e's direction, it also implies that n is in the subtree rooted in v + return false + } + return true + } + // else vroot is in the tail component and v is lower than u in the DFS tree + // if n is in a subtree rooted in v, it is also in a subtree rooted in u + // hence it's in the head component + return p.low[v] <= p.lim[n] && p.lim[n] <= p.lim[v] +} + +func (p *Processor) exchange(e, f *graph.Edge, g *graph.DGraph) { + if !e.IsInSpanningTree { + panic("network simplex: exchange: tree-edge not in spanning tree") + } + if f.IsInSpanningTree { + panic("network simplex: exchange: non-tree-edge already in spanning tree") + } + + d := slack(f) + if d > 0 { + // adjust the layer of nodes in e's tail component + for _, n := range g.Nodes { + if !p.inHeadComponent(n, e) { + n.Layer -= d + } + } + } + + // exchange the edges + e.IsInSpanningTree = false + f.IsInSpanningTree = true + + // recalculate the postorder numbers and edges' cut values + p.setStreeValues(g.Nodes[0]) + p.setCutValues(g) +} + +func (p *Processor) setStreeValues(n *graph.Node) { + clear(p.lim) + clear(p.low) + p.walkStreeDfs(n, graph.EdgeSet{}, 1) +} + +// Visits the nodes of the spanning tree in postorder traversal, assigning increasing indices. +// Same as a topological sorting; in addition, each node is mapped to a number low(n) +// which is the lowest postorder number in the subtree rooted in n. +// The root node will have low(n) = 1 and lim(n) = |V|; leaf nodes will have lim(n) = low(n). +func (p *Processor) walkStreeDfs(n *graph.Node, visited graph.EdgeSet, low int) int { + p.low[n] = low + lim := low + n.VisitEdges(func(e *graph.Edge) { + if e.IsInSpanningTree && !visited[e] { + visited[e] = true + lim = p.walkStreeDfs(e.ConnectedNode(n), visited, lim) + } + }) + p.lim[n] = lim + return lim + 1 +} + +// The cut value is defined as x - y where: +// - x = sum of the weights of all edges going from the tail to the head component, including the tree edge itself +// - y = sum of the weights of all edges from the head to the tail component +func (p *Processor) setCutValues(g *graph.DGraph) { + // todo naive implementation, optimize + for _, e := range g.Edges { + if !e.IsInSpanningTree { + continue + } + e.CutValue += e.Weight // e itself goes from tail to head by definition + + for _, f := range g.Edges { + // no other tree edge connects different components, otherwise we'd have two paths to e's target + if f.IsInSpanningTree { + continue + } + if !p.inHeadComponent(f.From, e) && p.inHeadComponent(f.To, e) { + e.CutValue += f.Weight + } else if p.inHeadComponent(f.From, e) && !p.inHeadComponent(f.To, e) { + e.CutValue -= f.Weight + } + } + } +} + +func slack(e *graph.Edge) int { + return e.To.Layer - e.From.Layer - e.Delta +} + +// shifts all layers up so that the lowest layer is 0 +func normalize(g *graph.DGraph) { + lowest := math.MaxInt + for _, n := range g.Nodes { + lowest = min(lowest, n.Layer) + } + if lowest == 0 { + return + } + for _, n := range g.Nodes { + n.Layer -= lowest + } +} + +// nodes are shifted to less crowded layers if the shift preserves feasibility (edge length >= edge delta) +func vbalance(g *graph.DGraph) { + lsize := map[int]int{} + lmax := 0 + for _, n := range g.Nodes { + lsize[n.Layer]++ + lmax = max(lmax, n.Layer) + } + + for _, n := range g.Nodes { + if n.Indeg() == n.Outdeg() { + low := 0 + high := lmax + for _, e := range n.In { + low = max(low, e.From.Layer+e.Delta) + } + for _, e := range n.Out { + high = min(high, e.To.Layer-e.Delta) + } + newl := low + + // if the node has only flat edges, or in/out-span 1, or is source/sink with span 1, this does nothing + // otherwise it may shift the node + for i := low + 1; i <= high; i++ { + if lsize[i] < lsize[newl] { + newl = i + } + } + if lsize[newl] < lsize[n.Layer] { + lsize[n.Layer]-- + lsize[newl]++ + n.Layer = newl + } + } + } +} + +func (p *Processor) hbalance(g *graph.DGraph) { + for _, e := range g.Edges { + if !e.IsInSpanningTree { + continue + } + if e.CutValue == 0 { + f := p.minSlackNonTreeEdge(g.Edges, e) + if f == nil { + continue + } + d := slack(f) + if d < 1 { + continue + } + if p.lim[e.From] < p.lim[e.To] { + p.adjustLayers(e.From, d) + } else { + p.adjustLayers(e.To, -d) + } + } + } +} + +func (p *Processor) adjustLayers(n *graph.Node, delta int) { + n.Layer -= delta + for _, e := range n.Out { + if !e.IsInSpanningTree { + continue + } + if !(p.lim[n] < p.lim[e.ConnectedNode(n)]) { + p.adjustLayers(e.To, delta) + } + } + for _, e := range n.In { + if !e.IsInSpanningTree { + continue + } + if !(p.lim[n] < p.lim[e.ConnectedNode(n)]) { + p.adjustLayers(e.From, delta) + } + } +} diff --git a/internal/ns/ns_test.go b/internal/ns/ns_test.go new file mode 100644 index 0000000..2d1bd88 --- /dev/null +++ b/internal/ns/ns_test.go @@ -0,0 +1,118 @@ +package ns + +import ( + "testing" + + "github.com/nulab/autog/internal/graph" + "github.com/stretchr/testify/assert" +) + +func TestSpanningTree(t *testing.T) { + g := &graph.DGraph{} + graph.EdgeSlice([][]string{ + {"a", "b"}, + {"b", "d"}, + {"b", "e"}, + {"a", "c"}, + {"c", "f"}, + {"c", "g"}, + {"c", "h"}, + {"f", "i"}, + }).Populate(g) + + for _, e := range g.Edges { + e.IsInSpanningTree = true + } + p := Processor{ + lim: make(graph.NodeIntMap), + low: make(graph.NodeIntMap), + } + p.setStreeValues(findNode(g, "a")) + + t.Run("low and lim", func(t *testing.T) { + type tc struct { + id string + low, lim int + } + tcs := []tc{ + {"a", 1, 9}, + {"b", 1, 3}, + {"c", 4, 8}, + {"d", 1, 1}, + {"e", 2, 2}, + {"f", 4, 5}, + {"g", 6, 6}, + {"h", 7, 7}, + {"i", 4, 4}, + } + + for _, tc := range tcs { + n := findNode(g, tc.id) + assert.Equalf(t, tc.low, p.low[n], "wrong low for n: %s", n.ID) + assert.Equalf(t, tc.lim, p.lim[n], "wrong lim for n: %s", n.ID) + } + }) + + t.Run("node in head component", func(t *testing.T) { + e := findEdge(g, "c", "f") + assert.True(t, p.inHeadComponent(findNode(g, "i"), e)) + assert.True(t, p.inHeadComponent(findNode(g, "f"), e)) + assert.False(t, p.inHeadComponent(findNode(g, "c"), e)) + assert.False(t, p.inHeadComponent(findNode(g, "d"), e)) + assert.False(t, p.inHeadComponent(findNode(g, "e"), e)) + assert.False(t, p.inHeadComponent(findNode(g, "h"), e)) + + e.Reverse() + assert.False(t, p.inHeadComponent(findNode(g, "i"), e)) + assert.False(t, p.inHeadComponent(findNode(g, "f"), e)) + assert.True(t, p.inHeadComponent(findNode(g, "c"), e)) + assert.True(t, p.inHeadComponent(findNode(g, "d"), e)) + assert.True(t, p.inHeadComponent(findNode(g, "e"), e)) + assert.True(t, p.inHeadComponent(findNode(g, "h"), e)) + + e = findEdge(g, "a", "b") + assert.False(t, p.inHeadComponent(findNode(g, "i"), e)) + assert.False(t, p.inHeadComponent(findNode(g, "f"), e)) + assert.False(t, p.inHeadComponent(findNode(g, "c"), e)) + assert.False(t, p.inHeadComponent(findNode(g, "a"), e)) + assert.True(t, p.inHeadComponent(findNode(g, "d"), e)) + assert.True(t, p.inHeadComponent(findNode(g, "e"), e)) + assert.False(t, p.inHeadComponent(findNode(g, "h"), e)) + + e.Reverse() + assert.True(t, p.inHeadComponent(findNode(g, "i"), e)) + assert.True(t, p.inHeadComponent(findNode(g, "f"), e)) + assert.True(t, p.inHeadComponent(findNode(g, "c"), e)) + assert.True(t, p.inHeadComponent(findNode(g, "a"), e)) + assert.False(t, p.inHeadComponent(findNode(g, "d"), e)) + assert.False(t, p.inHeadComponent(findNode(g, "e"), e)) + assert.True(t, p.inHeadComponent(findNode(g, "h"), e)) + + e = findEdge(g, "b", "e") + for _, n := range g.Nodes { + if n.ID == "e" { + assert.True(t, p.inHeadComponent(n, e)) + } else { + assert.False(t, p.inHeadComponent(n, e)) + } + } + }) +} + +func findNode(g *graph.DGraph, id string) *graph.Node { + for _, n := range g.Nodes { + if n.ID == id { + return n + } + } + return nil +} + +func findEdge(g *graph.DGraph, from, to string) *graph.Edge { + for _, e := range g.Edges { + if e.From.ID == from && e.To.ID == to { + return e + } + } + return nil +} diff --git a/internal/num/abs_test.go b/internal/num/abs_test.go new file mode 100644 index 0000000..b2c4dc0 --- /dev/null +++ b/internal/num/abs_test.go @@ -0,0 +1,13 @@ +package num + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAbs(t *testing.T) { + assert.Equal(t, 192, Abs(-192)) + assert.Equal(t, 56, Abs(56)) + assert.Equal(t, 0, Abs(0)) +} diff --git a/internal/phase1/cycle_test.go b/internal/phase1/cycle_test.go new file mode 100644 index 0000000..f509a72 --- /dev/null +++ b/internal/phase1/cycle_test.go @@ -0,0 +1,7 @@ +package phase1 + +import "testing" + +func TestCycle(t *testing.T) { + +} diff --git a/internal/phase2/alg_test.go b/internal/phase2/alg_test.go index 200613c..beb8f3c 100644 --- a/internal/phase2/alg_test.go +++ b/internal/phase2/alg_test.go @@ -3,6 +3,7 @@ package phase2 import ( "testing" + ig "github.com/nulab/autog/internal/graph" "github.com/stretchr/testify/assert" ) @@ -15,4 +16,11 @@ func TestAlg(t *testing.T) { assert.Equal(t, 2, i.Phase()) assert.Equal(t, strs[i], i.String()) } + assert.Equal(t, "", _endAlg.String()) +} + +func fromEdgeSlice(es [][]string) *ig.DGraph { + g := &ig.DGraph{} + ig.EdgeSlice(es).Populate(g) + return g } diff --git a/internal/phase2/longest_path.go b/internal/phase2/longest_path.go index c56653f..0131d9e 100644 --- a/internal/phase2/longest_path.go +++ b/internal/phase2/longest_path.go @@ -1,47 +1,34 @@ package phase2 import ( - "sort" - "github.com/nulab/autog/internal/graph" ) func execLongestPath(g *graph.DGraph) { - height := graph.NodeIntMap{} - for _, n := range g.Nodes { - height[n] = -1 + n.Layer = -1 } - nodes := make([]*graph.Node, len(g.Nodes)) - copy(nodes, g.Nodes) - - sort.Slice(nodes, func(i, j int) bool { - return nodes[i].Outdeg() > nodes[j].Outdeg() || - (nodes[i].Outdeg() == nodes[j].Outdeg() && nodes[i].Indeg() < nodes[j].Indeg()) - }) - - nlayers := 0 - for _, n := range nodes { - followLongestPath(n, height, &nlayers) + maxh := 0 + for _, n := range g.Nodes { + h := followLongestPath(n) + maxh = max(maxh, h) } } -func followLongestPath(n *graph.Node, height graph.NodeIntMap, nlayers *int) int { - if height[n] >= 0 { - return height[n] +func followLongestPath(n *graph.Node) int { + if n.Layer >= 0 { + return n.Layer } - nodeh := 1 - // sinks have no out-edges, so will yield 1 - for _, e := range n.Out { + maxh := 0 + for _, e := range n.In { if e.SelfLoops() { continue } - h := followLongestPath(e.ConnectedNode(n), height, nlayers) - nodeh = max(nodeh, h+e.Delta) + h := followLongestPath(e.ConnectedNode(n)) + maxh = max(maxh, h+1) } - *nlayers = max(*nlayers, nodeh) - n.Layer = *nlayers - nodeh - height[n] = nodeh - return nodeh + + n.Layer = maxh + return maxh } diff --git a/internal/phase2/longest_path_test.go b/internal/phase2/longest_path_test.go index 1515bad..e4682ec 100644 --- a/internal/phase2/longest_path_test.go +++ b/internal/phase2/longest_path_test.go @@ -1,20 +1,34 @@ package phase2 -// func TestLongestPath(t *testing.T) { -// g := graph.FromEdgeSlice([][]string{ -// {"F", "B"}, -// {"F", "Z"}, -// {"B", "A"}, -// {"B", "D"}, -// {"B", "K"}, -// {"Z", "a1"}, -// {"Z", "b1"}, -// {"A", "C"}, -// {"C", "U"}, -// }) -// -// execLongestPath(g) -// for _, n := range g.Nodes { -// fmt.Println(n.ID, n.Layer) -// } -// } +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestLongestPath(t *testing.T) { + g := fromEdgeSlice([][]string{ + {"F", "B"}, + {"F", "Z"}, + {"B", "A"}, + {"B", "D"}, + {"B", "K"}, + {"Z", "a1"}, + {"Z", "b1"}, + {"A", "C"}, + {"C", "U"}, + }) + + want := map[string]int{ + "F": 0, + "B": 1, "Z": 1, + "A": 2, "D": 2, "K": 2, "a1": 2, "b1": 2, + "C": 3, + "U": 4, + } + + execLongestPath(g) + for _, n := range g.Nodes { + assert.Equal(t, want[n.ID], n.Layer) + } +} diff --git a/internal/phase2/network_simplex.go b/internal/phase2/network_simplex.go index 5893951..a57316b 100644 --- a/internal/phase2/network_simplex.go +++ b/internal/phase2/network_simplex.go @@ -1,392 +1,10 @@ package phase2 import ( - "math" - "slices" - "github.com/nulab/autog/internal/graph" + "github.com/nulab/autog/internal/ns" ) -type networkSimplexProcessor struct { - lim graph.NodeIntMap // Gansner et al.: number from a root node in spanning tree postorder traversal - low graph.NodeIntMap // Gansner et al.: lowest postorder traversal number among nodes reachable from the input node -} - -// this implements a graph node layering algorithm, based on: -// - "Emden R. Gansner, Eleftherios Koutsofios, Stephen C. North, Kiem-Phong Vo, A technique for -// drawing directed graphs. Software Engineering 19(3), pp. 214-230, 1993." -// https://www.researchgate.net/publication/3187542_A_Technique_for_Drawing_Directed_Graphs -// - ELK Java code at https://github.com/eclipse/elk/blob/master/plugins/org.eclipse.elk.alg.layered/src/org/eclipse/elk/alg/layered/p2layers/NetworkSimplexLayerer.java func execNetworkSimplex(g *graph.DGraph, params graph.Params) { - p := &networkSimplexProcessor{ - lim: make(graph.NodeIntMap), - low: make(graph.NodeIntMap), - } - p.feasibleTree(g) - - // ELK defines the max iterations as an arbitrary user value N times a fixed factor K times the sqroot of |V|. - // where |V| is the number of nodes in each connected component. - // N*K in ELK defaults to 28. - k1 := int(math.Sqrt(float64(len(g.Nodes)))) - if params.NetworkSimplexMaxIterFactor > 0 { - k1 = params.NetworkSimplexMaxIterFactor - } - maxitr := int(params.NetworkSimplexThoroughness) * k1 - - e := negCutValueTreeEdge(g.Edges) - i := 0 - for e != nil { - if i >= maxitr { - break - } - f := p.minSlackNonTreeEdge(g.Edges, e) - // todo: figure out why this could be nil - if f == nil { - break - } - p.exchange(e, f, g) - e = negCutValueTreeEdge(g.Edges) - i++ - } - normalize(g) - switch params.NetworkSimplexBalance { - case 1: - vbalance(g) - case 2: - p.hbalance(g) - } -} - -// returns the first tree edge with negative cut value; -// may return nil if there is no such edge, meaning the solution is optimal. -func negCutValueTreeEdge(edges []*graph.Edge) *graph.Edge { - for _, e := range edges { - if e.IsInSpanningTree && e.CutValue < 0 { - return e - } - } - return nil -} - -// Replace candidates are non-tree edges that go from e's head component to its tail component (original direction). -// The first candidate with minimum slack is chosen. -func (p *networkSimplexProcessor) minSlackNonTreeEdge(edges []*graph.Edge, e *graph.Edge) *graph.Edge { - var minSlack = math.MaxInt - var replaceCandidate *graph.Edge - for _, f := range edges { - if f == e || f.IsInSpanningTree { - continue - } - if p.inHeadComponent(f.From, e) && !p.inHeadComponent(f.To, e) { - slack := slack(f) - if slack < minSlack { - minSlack = slack - replaceCandidate = f - } - } - } - return replaceCandidate // nil if not otherwise assigned -} - -// computes an initial feasible spanning tree; it's feasible if its edges are tight -func (p *networkSimplexProcessor) feasibleTree(g *graph.DGraph) { - p.initLayers(g) - for { - treeNodes := tightTree(g.Nodes[0], graph.EdgeSet{}, graph.NodeSet{}) - if len(treeNodes) == len(g.Nodes) { - break - } - e := p.incidentNonTreeEdge(treeNodes) - // incident means that one of e's vertices belongs to the tree and one doesn't. - // here e's slack must be >= 0: since it points to a non-tree node, if the slack - // were 0 it would've been included in the tight tree. - // then, the layers of tree nodes are adjusted to make e's slack equal to zero. - // as the edge becomes tight, it will be included in the tree together with its non-tree vertex - // at the next iteration. - d := slack(e) - if treeNodes[e.To] { - d = -d - } - for n := range treeNodes { - n.Layer += d - } - } - - p.setStreeValues(g.Nodes[0]) - p.setCutValues(g) -} - -// Starting from the graph source nodes (no incoming edges), this method assigns an initial layer -// to each node n based on the maximum layer of nodes connected to them via incoming edges. -// This makes all directed edges point downward. -// Source nodes have no incoming edges, so are processed first. -func (p *networkSimplexProcessor) initLayers(g *graph.DGraph) { - // initialize the count of incoming edges for all nodes - unseenInEdges := make(graph.NodeIntMap) - for _, n := range g.Nodes { - unseenInEdges[n] = n.Indeg() - } - - // sources have layer 0 - sources := slices.Collect(g.Sources()) - - for len(sources) > 0 { - n := sources[0] - sources = sources[1:] - - // given a directed edge e = (n,m) - // the target node m is assigned the layer of the source node plus delta - // this makes the edge tight by construction because slack(e) = 0 - for _, e := range n.Out { - m := e.To - m.Layer = max(m.Layer, n.Layer+e.Delta) - unseenInEdges[m]-- - if unseenInEdges[m] == 0 { - sources = append(sources, m) - } - } - } -} - -// Starting from the given node, this constructs a spanning tree with only tight edges (slack = 0). -// It returns visitedNodes which contains the nodes that belong to this spanning tree. -func tightTree(n *graph.Node, visitedEdges graph.EdgeSet, visitedNodes graph.NodeSet) graph.NodeSet { - visitedNodes[n] = true - n.VisitEdges(func(e *graph.Edge) { - if !visitedEdges[e] { - visitedEdges[e] = true - m := e.ConnectedNode(n) - if e.IsInSpanningTree { - tightTree(m, visitedEdges, visitedNodes) - } else if !visitedNodes[m] && slack(e) == 0 { - // checking that m hasn't been seen before ensures there are no loopbacks in this spanning tree - e.IsInSpanningTree = true - tightTree(m, visitedEdges, visitedNodes) - } - } - }) - return visitedNodes -} - -// This finds a "non-tree edge incident on the tree with min amount of slack". -// Incident means that only one of the edge's vertices belongs to the spanning tree. -func (p *networkSimplexProcessor) incidentNonTreeEdge(treeNodes graph.NodeSet) *graph.Edge { - var minSlack = math.MaxInt - var candidate *graph.Edge - // todo: range on map non-deterministic - for n := range treeNodes { - n.VisitEdges(func(e *graph.Edge) { - if e.SelfLoops() { - return - } - if e.IsInSpanningTree || treeNodes[e.ConnectedNode(n)] { - return - } - slack := slack(e) - if slack < minSlack { - minSlack = slack - candidate = e - } - }) - } - if candidate == nil { - panic("network simplex: did not find adjacent non-tree edge with min slack: make sure the graph is connected") - } - return candidate -} - -func (p *networkSimplexProcessor) inHeadComponent(n *graph.Node, e *graph.Edge) bool { - if !e.IsInSpanningTree { - panic("network simplex: breaking tree around non-tree edge") - } - u, v := e.From, e.To - - // the following boolean logic follows Graphviz's paper: - // "For example, if e = (u,v) is a tree edge and vroot is in the head component of the edge (i.e., lim(u) < lim(v)), - // then a node w is in the tail component of e if and only if low(u) ≤ lim(w) ≤ lim(u)." - if p.lim[u] < p.lim[v] { - if p.low[u] <= p.lim[n] && p.lim[n] <= p.lim[u] { - // this inequality means that n is in the subtree rooted in u; - // because of e's direction, it also implies that n is in the subtree rooted in v - return false - } - return true - } - // else vroot is in the tail component and v is lower than u in the DFS tree - // if n is in a subtree rooted in v, it is also in a subtree rooted in u - // hence it's in the head component - return p.low[v] <= p.lim[n] && p.lim[n] <= p.lim[v] -} - -func (p *networkSimplexProcessor) exchange(e, f *graph.Edge, g *graph.DGraph) { - if !e.IsInSpanningTree { - panic("network simplex: exchange: tree-edge not in spanning tree") - } - if f.IsInSpanningTree { - panic("network simplex: exchange: non-tree-edge already in spanning tree") - } - - d := slack(f) - if d > 0 { - // adjust the layer of nodes in e's tail component - for _, n := range g.Nodes { - if !p.inHeadComponent(n, e) { - n.Layer -= d - } - } - } - - // exchange the edges - e.IsInSpanningTree = false - f.IsInSpanningTree = true - - // recalculate the postorder numbers and edges' cut values - p.setStreeValues(g.Nodes[0]) - p.setCutValues(g) -} - -func (p *networkSimplexProcessor) setStreeValues(n *graph.Node) { - clear(p.lim) - clear(p.low) - p.walkStreeDfs(n, graph.EdgeSet{}, 1) -} - -// Visits the nodes of the spanning tree in postorder traversal, assigning increasing indices. -// Same as a topological sorting; in addition, each node is mapped to a number low(n) -// which is the lowest postorder number in the subtree rooted in n. -// The root node will have low(n) = 1 and lim(n) = |V|; leaf nodes will have lim(n) = low(n). -func (p *networkSimplexProcessor) walkStreeDfs(n *graph.Node, visited graph.EdgeSet, low int) int { - p.low[n] = low - lim := low - n.VisitEdges(func(e *graph.Edge) { - if e.IsInSpanningTree && !visited[e] { - visited[e] = true - lim = p.walkStreeDfs(e.ConnectedNode(n), visited, lim) - } - }) - p.lim[n] = lim - return lim + 1 -} - -// The cut value is defined as x - y where: -// - x = sum of the weights of all edges going from the tail to the head component, including the tree edge itself -// - y = sum of the weights of all edges from the head to the tail component -func (p *networkSimplexProcessor) setCutValues(g *graph.DGraph) { - // todo naive implementation, optimize - for _, e := range g.Edges { - if !e.IsInSpanningTree { - continue - } - e.CutValue += e.Weight // e itself goes from tail to head by definition - - for _, f := range g.Edges { - // no other tree edge connects different components, otherwise we'd have two paths to e's target - if f.IsInSpanningTree { - continue - } - if !p.inHeadComponent(f.From, e) && p.inHeadComponent(f.To, e) { - e.CutValue += f.Weight - } else if p.inHeadComponent(f.From, e) && !p.inHeadComponent(f.To, e) { - e.CutValue -= f.Weight - } - } - } -} - -func slack(e *graph.Edge) int { - return e.To.Layer - e.From.Layer - e.Delta -} - -// shifts all layers up so that the lowest layer is 0 -func normalize(g *graph.DGraph) { - lowest := math.MaxInt - for _, n := range g.Nodes { - lowest = min(lowest, n.Layer) - } - if lowest == 0 { - return - } - for _, n := range g.Nodes { - n.Layer -= lowest - } -} - -// nodes are shifted to less crowded layers if the shift preserves feasibility (edge length >= edge delta) -func vbalance(g *graph.DGraph) { - lsize := map[int]int{} - lmax := 0 - for _, n := range g.Nodes { - lsize[n.Layer]++ - lmax = max(lmax, n.Layer) - } - - for _, n := range g.Nodes { - if n.Indeg() == n.Outdeg() { - low := 0 - high := lmax - for _, e := range n.In { - low = max(low, e.From.Layer+e.Delta) - } - for _, e := range n.Out { - high = min(high, e.To.Layer-e.Delta) - } - newl := low - - // if the node has only flat edges, or in/out-span 1, or is source/sink with span 1, this does nothing - // otherwise it may shift the node - for i := low + 1; i <= high; i++ { - if lsize[i] < lsize[newl] { - newl = i - } - } - if lsize[newl] < lsize[n.Layer] { - lsize[n.Layer]-- - lsize[newl]++ - n.Layer = newl - } - } - } -} - -func (p *networkSimplexProcessor) hbalance(g *graph.DGraph) { - for _, e := range g.Edges { - if !e.IsInSpanningTree { - continue - } - if e.CutValue == 0 { - f := p.minSlackNonTreeEdge(g.Edges, e) - if f == nil { - continue - } - d := slack(f) - if d < 1 { - continue - } - if p.lim[e.From] < p.lim[e.To] { - p.adjustLayers(e.From, d) - } else { - p.adjustLayers(e.To, -d) - } - } - } -} - -func (p *networkSimplexProcessor) adjustLayers(n *graph.Node, delta int) { - n.Layer -= delta - for _, e := range n.Out { - if !e.IsInSpanningTree { - continue - } - if !(p.lim[n] < p.lim[e.ConnectedNode(n)]) { - p.adjustLayers(e.To, delta) - } - } - for _, e := range n.In { - if !e.IsInSpanningTree { - continue - } - if !(p.lim[n] < p.lim[e.ConnectedNode(n)]) { - p.adjustLayers(e.From, delta) - } - } + new(ns.Processor).Exec(g, params) } diff --git a/internal/phase2/network_simplex_test.go b/internal/phase2/network_simplex_test.go index fb7791e..839887c 100644 --- a/internal/phase2/network_simplex_test.go +++ b/internal/phase2/network_simplex_test.go @@ -1,131 +1,86 @@ -//go:build unit - package phase2 import ( "testing" - egraph "github.com/nulab/autog/graph" - "github.com/nulab/autog/internal/graph" - "github.com/nulab/autog/internal/testfiles" + ig "github.com/nulab/autog/internal/graph" "github.com/stretchr/testify/assert" ) -func TestSpanningTree(t *testing.T) { - g := fromEdgeSlice([][]string{ - {"a", "b"}, - {"b", "d"}, - {"b", "e"}, - {"a", "c"}, - {"c", "f"}, - {"c", "g"}, - {"c", "h"}, - {"f", "i"}, - }) - for _, e := range g.Edges { - e.IsInSpanningTree = true - } - p := newNsProcessor() - p.setStreeValues(findNode(g, "a")) - - t.Run("low and lim", func(t *testing.T) { - type tc struct { - id string - low, lim int - } - tcs := []tc{ - {"a", 1, 9}, - {"b", 1, 3}, - {"c", 4, 8}, - {"d", 1, 1}, - {"e", 2, 2}, - {"f", 4, 5}, - {"g", 6, 6}, - {"h", 7, 7}, - {"i", 4, 4}, - } - - for _, tc := range tcs { - n := findNode(g, tc.id) - assert.Equalf(t, tc.low, p.low[n], "wrong low for n: %s", n.ID) - assert.Equalf(t, tc.lim, p.lim[n], "wrong lim for n: %s", n.ID) - } - }) - - t.Run("node in head component", func(t *testing.T) { - e := findEdge(g, "c", "f") - assert.True(t, p.inHeadComponent(findNode(g, "i"), e)) - assert.True(t, p.inHeadComponent(findNode(g, "f"), e)) - assert.False(t, p.inHeadComponent(findNode(g, "c"), e)) - assert.False(t, p.inHeadComponent(findNode(g, "d"), e)) - assert.False(t, p.inHeadComponent(findNode(g, "e"), e)) - assert.False(t, p.inHeadComponent(findNode(g, "h"), e)) - - e.Reverse() - assert.False(t, p.inHeadComponent(findNode(g, "i"), e)) - assert.False(t, p.inHeadComponent(findNode(g, "f"), e)) - assert.True(t, p.inHeadComponent(findNode(g, "c"), e)) - assert.True(t, p.inHeadComponent(findNode(g, "d"), e)) - assert.True(t, p.inHeadComponent(findNode(g, "e"), e)) - assert.True(t, p.inHeadComponent(findNode(g, "h"), e)) - - e = findEdge(g, "a", "b") - assert.False(t, p.inHeadComponent(findNode(g, "i"), e)) - assert.False(t, p.inHeadComponent(findNode(g, "f"), e)) - assert.False(t, p.inHeadComponent(findNode(g, "c"), e)) - assert.False(t, p.inHeadComponent(findNode(g, "a"), e)) - assert.True(t, p.inHeadComponent(findNode(g, "d"), e)) - assert.True(t, p.inHeadComponent(findNode(g, "e"), e)) - assert.False(t, p.inHeadComponent(findNode(g, "h"), e)) - - e.Reverse() - assert.True(t, p.inHeadComponent(findNode(g, "i"), e)) - assert.True(t, p.inHeadComponent(findNode(g, "f"), e)) - assert.True(t, p.inHeadComponent(findNode(g, "c"), e)) - assert.True(t, p.inHeadComponent(findNode(g, "a"), e)) - assert.False(t, p.inHeadComponent(findNode(g, "d"), e)) - assert.False(t, p.inHeadComponent(findNode(g, "e"), e)) - assert.True(t, p.inHeadComponent(findNode(g, "h"), e)) - - e = findEdge(g, "b", "e") - for _, n := range g.Nodes { - if n.ID == "e" { - assert.True(t, p.inHeadComponent(n, e)) - } else { - assert.False(t, p.inHeadComponent(n, e)) - } - } - }) -} - -func newNsProcessor() *networkSimplexProcessor { - return &networkSimplexProcessor{ - lim: graph.NodeIntMap{}, - low: graph.NodeIntMap{}, - } -} - -func findNode(g *graph.DGraph, id string) *graph.Node { - for _, n := range g.Nodes { - if n.ID == id { - return n - } - } - return nil -} - -func findEdge(g *graph.DGraph, from, to string) *graph.Edge { - for _, e := range g.Edges { - if e.From.ID == from && e.To.ID == to { - return e - } - } - return nil -} - func TestNSLayering(t *testing.T) { - g := fromEdgeSlice(testfiles.DotAbstract) - execNetworkSimplex(g, graph.Params{NetworkSimplexThoroughness: 28, NetworkSimplexBalance: 1}) + g := &ig.DGraph{} + ig.EdgeSlice([][]string{ + {"S24", "27"}, + {"S24", "25"}, + {"S1", "10"}, + {"S1", "2"}, + {"S35", "36"}, + {"S35", "43"}, + {"S30", "31"}, + {"S30", "33"}, + {"9", "42"}, + {"9", "T1"}, + {"25", "T1"}, + {"25", "26"}, + {"27", "T24"}, + {"2", "3"}, + {"2", "16"}, + {"2", "17"}, + {"2", "T1"}, + {"2", "18"}, + {"10", "11"}, + {"10", "14"}, + {"10", "T1"}, + {"10", "13"}, + {"10", "12"}, + {"31", "T1"}, + {"31", "32"}, + {"33", "T30"}, + {"33", "34"}, + {"42", "4"}, + {"26", "4"}, + {"3", "4"}, + {"16", "15"}, + {"17", "19"}, + {"18", "29"}, + {"11", "4"}, + {"14", "15"}, + {"37", "39"}, + {"37", "41"}, + {"37", "38"}, + {"37", "40"}, + {"13", "19"}, + {"12", "29"}, + {"43", "38"}, + {"43", "40"}, + {"36", "19"}, + {"32", "23"}, + {"34", "29"}, + {"39", "15"}, + {"41", "29"}, + {"38", "4"}, + {"40", "19"}, + {"4", "5"}, + {"19", "21"}, + {"19", "20"}, + {"19", "28"}, + {"5", "6"}, + {"5", "T35"}, + {"5", "23"}, + {"21", "22"}, + {"20", "15"}, + {"28", "29"}, + {"6", "7"}, + {"15", "T1"}, + {"22", "23"}, + {"22", "T35"}, + {"29", "T30"}, + {"7", "T8"}, + {"23", "T24"}, + {"23", "T1"}, + }).Populate(g) + + execNetworkSimplex(g, ig.Params{NetworkSimplexThoroughness: 28, NetworkSimplexBalance: 1}) want := expectedLayersAbstract() for _, n := range g.Nodes { @@ -152,9 +107,3 @@ func expectedLayersAbstract() map[string]int { "T8": 8, } } - -func fromEdgeSlice(es [][]string) *graph.DGraph { - g := &graph.DGraph{} - egraph.EdgeSlice(es).Populate(g) - return g -} diff --git a/internal/phase3/alg_test.go b/internal/phase3/alg_test.go index b8bbbb7..8161b93 100644 --- a/internal/phase3/alg_test.go +++ b/internal/phase3/alg_test.go @@ -15,4 +15,5 @@ func TestAlg(t *testing.T) { assert.Equal(t, 3, i.Phase()) assert.Equal(t, strs[i], i.String()) } + assert.Equal(t, "", _endAlg.String()) } diff --git a/internal/phase3/wmedian.go b/internal/phase3/wmedian.go index 5f27ebc..3d552f3 100644 --- a/internal/phase3/wmedian.go +++ b/internal/phase3/wmedian.go @@ -35,6 +35,13 @@ func execWeightedMedian(g *graph.DGraph, params graph.Params) { // insert virtual nodes so that edges with length >1 have length 1 breakLongEdges(g) + // set size to virtual nodes if needed + if s := params.VirtualNodeFixedSize; s > 0.0 { + for n := range g.VirtualNodes() { + n.W = s + n.H = s + } + } maxiter := int(params.WMedianMaxIter) fixedPositions := initFixedPositions(g.Edges) diff --git a/internal/phase3/wmedian_fixedpos.go b/internal/phase3/wmedian_fixedpos.go index eca5168..4f9b109 100644 --- a/internal/phase3/wmedian_fixedpos.go +++ b/internal/phase3/wmedian_fixedpos.go @@ -74,14 +74,6 @@ func initFixedPositions(edges []*graph.Edge) fixedPositions { return fixedPositions{mustAfter, mustBefore} } -func walkFlat(n *graph.Node, visited graph.EdgeSet) { - for _, f := range n.Out { - if visited[f] { - continue - } - } -} - // head returns the first element in a same-layer transitive closure to which k belongs, and the number of edges // that separate k and the head; // or returns k itself and 0 if k doesn't belong to any such closure diff --git a/internal/phase4/network_simplex.go b/internal/phase4/network_simplex.go index 9b75af0..4d9ece8 100644 --- a/internal/phase4/network_simplex.go +++ b/internal/phase4/network_simplex.go @@ -5,7 +5,7 @@ import ( "strconv" "github.com/nulab/autog/internal/graph" - "github.com/nulab/autog/internal/phase2" + "github.com/nulab/autog/internal/ns" ) type networkSimplexProcessor struct { @@ -30,7 +30,7 @@ func execNetworkSimplex(g *graph.DGraph, params graph.Params) { // todo: if there are flat edges, dot adds auxiliary edges aux := p.auxiliaryGraph(g) - phase2.NetworkSimplex.Process( + new(ns.Processor).Exec( aux, graph.Params{ NetworkSimplexThoroughness: params.NetworkSimplexThoroughness, diff --git a/internal/phase5/route_merge_test.go b/internal/phase5/route_merge_test.go index 8a64c47..6e640c1 100644 --- a/internal/phase5/route_merge_test.go +++ b/internal/phase5/route_merge_test.go @@ -4,7 +4,6 @@ import ( "sort" "testing" - egraph "github.com/nulab/autog/graph" "github.com/nulab/autog/internal/graph" "github.com/stretchr/testify/assert" ) @@ -151,6 +150,6 @@ func inIds(n *graph.Node) []string { func fromEdgeSlice(es [][]string) *graph.DGraph { g := &graph.DGraph{} - egraph.EdgeSlice(es).Populate(g) + graph.EdgeSlice(es).Populate(g) return g } diff --git a/internal/phase5/splines.go b/internal/phase5/splines.go index 8b3f0e9..a18e441 100644 --- a/internal/phase5/splines.go +++ b/internal/phase5/splines.go @@ -85,7 +85,7 @@ func rectBetweenLayers(l1, l2 *graph.Layer) geom.Rect { h1, h2 := l1.Head(), l2.Head() t1, t2 := l2.Tail(), l2.Tail() return geom.Rect{ - TL: geom.P{min(h1.X, h2.X), h1.Y + h1.H}, + TL: geom.P{min(h1.X, h2.X), h1.Y + max(h1.H, l1.H)}, BR: geom.P{max(t1.X+t1.W, t2.X+t2.W), t2.Y}, } } @@ -104,8 +104,8 @@ func rectVirtualNode(vn *graph.Node, vl *graph.Layer) geom.Rect { // this p-1 access is safe: a layer cannot contain only one virtual node n := vl.Nodes[p-1] return geom.Rect{ - TL: geom.P{n.X + n.W, n.Y}, - BR: geom.P{vn.X + 10, n.Y + n.H}, + TL: geom.P{n.X + n.W + 10, n.Y}, + BR: geom.P{vn.X, n.Y + n.H}, } default: diff --git a/internal/testfiles/adjacency_lists.go b/internal/testfiles/adjacency_lists.go deleted file mode 100644 index 33b7117..0000000 --- a/internal/testfiles/adjacency_lists.go +++ /dev/null @@ -1,247 +0,0 @@ -//go:build unit - -package testfiles - -// This file contains graph adjancency lists used in regression tests - -var issues1and4 = [][]string{ - {"N1", "N2"}, - {"N3", "N1"}, - {"N2", "N3"}, - {"Nh", "N1"}, - {"Nk", "N1"}, - {"Na", "N2"}, - {"Na", "N3"}, - {"N2", "Nd"}, -} - -var issue9 = [][]string{ - {"N1", "N4"}, - {"N1", "N8"}, - {"N2", "N5"}, - {"N2", "N8"}, - {"N3", "N8"}, - {"N6", "N3"}, - {"N8", "N1"}, - {"N8", "N2"}, - {"N8", "N7"}, - {"N8", "N15"}, - {"N8", "N16"}, - {"N9", "N10"}, - {"N10", "N11"}, - {"N12", "N13"}, - {"N13", "N14"}, - {"N15", "N1"}, - {"N15", "N9"}, - {"N15", "N10"}, - {"N16", "N2"}, - {"N16", "N12"}, - {"N16", "N13"}, -} - -var simpleVirtualNodes = [][]string{ - {"N1", "N2"}, - {"N2", "N3"}, - {"N1", "N3"}, -} - -var cacooArch = [][]string{ - {"gql", "acc"}, - {"gql", "dia"}, - {"gql", "edt"}, - {"gql", "fld"}, - {"gql", "itg"}, - {"gql", "ntf"}, - {"gql", "org"}, - {"gql", "sub"}, - {"gql", "spt"}, - {"gql", "tmp"}, - {"acc", "lgc"}, - {"acc", "sub"}, - {"fld", "acc"}, - {"fld", "dia"}, - {"fld", "org"}, - {"fld", "sub"}, - {"dia", "acc"}, - {"dia", "fld"}, - {"dia", "lgc"}, - {"dia", "org"}, - {"dia", "sub"}, -} - -var cacooArch2 = [][]string{ - {"gql", "acc"}, - {"gql", "dia"}, - {"gql", "edt"}, - {"gql", "fld"}, - {"gql", "itg"}, - {"gql", "ntf"}, - {"gql", "org"}, - {"gql", "sub"}, - {"gql", "spt"}, - {"gql", "tmp"}, - {"acc", "lgc"}, - {"acc", "sub"}, - {"dia", "acc"}, - {"dia", "fld"}, - {"dia", "lgc"}, - {"dia", "org"}, - {"dia", "sub"}, - {"fld", "acc"}, - {"fld", "dia"}, - {"fld", "org"}, - {"fld", "sub"}, -} - -var DotAbstract = [][]string{ - {"S24", "27"}, - {"S24", "25"}, - {"S1", "10"}, - {"S1", "2"}, - {"S35", "36"}, - {"S35", "43"}, - {"S30", "31"}, - {"S30", "33"}, - {"9", "42"}, - {"9", "T1"}, - {"25", "T1"}, - {"25", "26"}, - {"27", "T24"}, - {"2", "3"}, - {"2", "16"}, - {"2", "17"}, - {"2", "T1"}, - {"2", "18"}, - {"10", "11"}, - {"10", "14"}, - {"10", "T1"}, - {"10", "13"}, - {"10", "12"}, - {"31", "T1"}, - {"31", "32"}, - {"33", "T30"}, - {"33", "34"}, - {"42", "4"}, - {"26", "4"}, - {"3", "4"}, - {"16", "15"}, - {"17", "19"}, - {"18", "29"}, - {"11", "4"}, - {"14", "15"}, - {"37", "39"}, - {"37", "41"}, - {"37", "38"}, - {"37", "40"}, - {"13", "19"}, - {"12", "29"}, - {"43", "38"}, - {"43", "40"}, - {"36", "19"}, - {"32", "23"}, - {"34", "29"}, - {"39", "15"}, - {"41", "29"}, - {"38", "4"}, - {"40", "19"}, - {"4", "5"}, - {"19", "21"}, - {"19", "20"}, - {"19", "28"}, - {"5", "6"}, - {"5", "T35"}, - {"5", "23"}, - {"21", "22"}, - {"20", "15"}, - {"28", "29"}, - {"6", "7"}, - {"15", "T1"}, - {"22", "23"}, - {"22", "T35"}, - {"29", "T30"}, - {"7", "T8"}, - {"23", "T24"}, - {"23", "T1"}, -} - -var elkTestGraphs = []struct { - name string - adj [][]string -}{ - {"lib_decg_DECGPi", lib_decg_DECGPi}, - {"pn_brockackerman_BrockAckerman", pn_brockackerman_BrockAckerman}, -} - -var lib_decg_DECGPi = [][]string{ - {"N2", "N8"}, - {"N2", "N13"}, - {"N2", "N15"}, - {"N2", "N4"}, - {"N3", "N1"}, - {"N4", "N3"}, - {"N5", "N16"}, - {"N5", "N18"}, - {"N6", "N8"}, - {"N6", "N18"}, - {"N7", "N6"}, - {"N8", "N5"}, - {"N8", "N9"}, - {"N9", "N6"}, - {"N9", "N7"}, - {"N10", "N14"}, - {"N10", "N19"}, - {"N11", "N10"}, - {"N12", "N10"}, - {"N12", "N11"}, - {"N13", "N14"}, - {"N14", "N17"}, - {"N14", "N12"}, - {"N15", "N13"}, - {"N16", "N4"}, - {"N17", "N16"}, - {"N17", "N19"}, - {"N18", "N5"}, - {"N19", "N17"}, -} - -var pn_brockackerman_BrockAckerman = [][]string{ - {"N1", "N2"}, - {"N2", "N9"}, - {"N4", "N10"}, - {"N4", "N15"}, - {"N5", "N6"}, - {"N6", "N14"}, - {"N8", "N1"}, - {"N8", "N3"}, - {"N9", "N12"}, - {"N10", "N11"}, - {"N11", "N12"}, - {"N12", "N8"}, - {"N13", "N5"}, - {"N13", "N7"}, - {"N14", "N16"}, - {"N15", "N16"}, - {"N16", "N13"}, -} - -var singleSelfLoop = [][]string{ - {"a", "a"}, -} - -var withSelfLoop = [][]string{ - {"a", "b"}, - {"b", "c"}, - {"b", "b"}, - {"a", "d"}, -} - -var bkWrongAlignment = [][]string{ - {"a", "b"}, - {"b", "c"}, - {"a", "f"}, - {"f", "g"}, - {"a", "u"}, - {"f", "c"}, - {"c", "k"}, - {"f", "k"}, -} diff --git a/internal/testfiles/bugfix_test.go b/internal/testfiles/bugfix_test.go deleted file mode 100644 index 845b243..0000000 --- a/internal/testfiles/bugfix_test.go +++ /dev/null @@ -1,130 +0,0 @@ -//go:build unit - -package testfiles - -import ( - "fmt" - "testing" - - "github.com/nulab/autog" - "github.com/nulab/autog/graph" - ig "github.com/nulab/autog/internal/graph" - imonitor "github.com/nulab/autog/internal/monitor" - "github.com/stretchr/testify/assert" -) - -func TestCrashers(t *testing.T) { - t.Run("phase1", func(t *testing.T) { - t.Run("greedy cycle breaker fails to break cycles", func(t *testing.T) { - assert.NotPanics(t, func() { - _ = autog.Layout( - graph.EdgeSlice(issue9), - autog.WithNonDeterministicGreedyCycleBreaker(), - ) - }) - }) - }) - - t.Run("phase3 WMedian", func(t *testing.T) { - t.Run("identical edge segfault in cross counting", func(t *testing.T) { - src := graph.EdgeSlice(cacooArch) - assert.NotPanics(t, func() { _ = autog.Layout(src) }) - }) - - t.Run("wrong initialization of flat edges", func(t *testing.T) { - src := graph.EdgeSlice(cacooArch2) - assert.NotPanics(t, func() { _ = autog.Layout(src) }) - }) - - t.Run("wrong handling of fixed positions in wmedian", func(t *testing.T) { - c := make(chan any, 1) - assert.NotPanics(t, func() { - _ = autog.Layout( - graph.EdgeSlice(DotAbstract), - autog.WithPositioning(autog.PositioningNoop), - autog.WithEdgeRouting(autog.EdgeRoutingNoop), - autog.WithMonitor(imonitor.NewFilteredChan(c, imonitor.MatchAll(3, "gvdot", "crossings"))), - ) - }) - - assert.Equal(t, 46, <-c) - }) - }) - - t.Run("phase4 SinkColoring", func(t *testing.T) { - t.Run("program hangs", func(t *testing.T) { - src := graph.EdgeSlice(issues1and4) - assert.NotPanics(t, func() { _ = autog.Layout(src, autog.WithPositioning(autog.PositioningSinkColoring)) }) - }) - }) - - t.Run("phase4 NetworkSimplex", func(t *testing.T) { - g := &ig.DGraph{} - graph.EdgeSlice(DotAbstract).Populate(g) - assert.NotPanics(t, func() { - _ = autog.Layout( - g, - autog.WithPositioning(autog.PositioningNetworkSimplex), - autog.WithEdgeRouting(autog.EdgeRoutingNoop), - autog.WithNodeFixedSize(100, 100), - ) - }) - // reduce expectations due to non-determinism - // assertNoOverlaps(t, g, 1) - }) - - t.Run("phase4 B&K", func(t *testing.T) { - t.Run("no overlaps", func(t *testing.T) { - g := &ig.DGraph{} - graph.EdgeSlice(bkWrongAlignment).Populate(g) - _ = autog.Layout(g, - autog.WithPositioning(autog.PositioningBrandesKoepf), - autog.WithNodeFixedSize(130, 60), - ) - - assertNoOverlaps(t, g, 0) - }) - }) - - t.Run("output layout empty nodes", func(t *testing.T) { - src := graph.EdgeSlice(simpleVirtualNodes) - layout := autog.Layout( - src, - autog.WithPositioning(autog.PositioningVAlign), - autog.WithEdgeRouting(autog.EdgeRoutingNoop), - ) - assert.Len(t, layout.Nodes, 3) - assert.Len(t, layout.Edges, 3) - }) - - t.Run("self-loop", func(t *testing.T) { - t.Run("program halts", func(t *testing.T) { - src := graph.EdgeSlice(withSelfLoop) - assert.NotPanics(t, func() { _ = autog.Layout(src) }) - }) - t.Run("successful with single node", func(t *testing.T) { - src := graph.EdgeSlice(singleSelfLoop) - assert.NotPanics(t, func() { _ = autog.Layout(src) }) - }) - }) -} - -func assertNoOverlaps(t *testing.T, g *ig.DGraph, tolerance int) { - overlaps := 0 - for _, l := range g.Layers { - for j := 1; j < l.Len(); j++ { - cur := l.Nodes[j] - prv := l.Nodes[j-1] - - if prv.X+prv.W > cur.X { - if overlaps >= tolerance { - // note: this isn't a strict inequality because virtual nodes have size 0x0 - assert.Truef(t, prv.X+prv.W <= cur.X, "%s(X:%.2f) overlaps %s(X+W:%.2f)", cur, cur.X, prv, prv.X+prv.W) - } else { - fmt.Printf("warning: overlap between nodes %v and %v within tolerance\n", cur, prv) - } - overlaps++ - } - } - } -} diff --git a/internal/testfiles/feature_test.go b/internal/testfiles/feature_test.go deleted file mode 100644 index 444f219..0000000 --- a/internal/testfiles/feature_test.go +++ /dev/null @@ -1,43 +0,0 @@ -//go:build unit - -package testfiles - -import ( - "testing" - - "github.com/nulab/autog" - "github.com/nulab/autog/graph" - ig "github.com/nulab/autog/internal/graph" - "github.com/stretchr/testify/assert" -) - -func TestOutputVirtualNodes(t *testing.T) { - t.Run("keep virtual nodes", func(t *testing.T) { - g := &ig.DGraph{} - graph.EdgeSlice(simpleVirtualNodes).Populate(g) - layout := autog.Layout( - g, - autog.WithPositioning(autog.PositioningVAlign), - autog.WithEdgeRouting(autog.EdgeRoutingNoop), - autog.WithOutputVirtualNodes(true), - ) - assert.Len(t, layout.Nodes, 4) - assert.Len(t, layout.Edges, 3) - }) - - t.Run("clip output nodes", func(t *testing.T) { - g := &ig.DGraph{} - graph.EdgeSlice(simpleVirtualNodes).Populate(g) - layout := autog.Layout( - g, - autog.WithPositioning(autog.PositioningVAlign), - autog.WithEdgeRouting(autog.EdgeRoutingNoop), - autog.WithOutputVirtualNodes(false), - ) - assert.Equal(t, 3, len(layout.Nodes)) - assert.Equal(t, 3, cap(layout.Nodes)) - assert.Equal(t, 4, len(g.Nodes)) - - assert.Len(t, layout.Edges, 3) - }) -} diff --git a/internal/testfiles/regression_test.go b/internal/testfiles/regression_test.go deleted file mode 100644 index 99b990e..0000000 --- a/internal/testfiles/regression_test.go +++ /dev/null @@ -1,25 +0,0 @@ -//go:build unit - -package testfiles - -import ( - "testing" - - "github.com/nulab/autog" - "github.com/nulab/autog/graph" - "github.com/stretchr/testify/assert" -) - -func TestNoRegression(t *testing.T) { - t.Run("ELK", func(t *testing.T) { - for _, testcase := range elkTestGraphs { - t.Run(testcase.name, func(t *testing.T) { - assert.NotPanics(t, func() { autog.Layout(graph.EdgeSlice(testcase.adj)) }) - }) - } - }) - - t.Run("Dot", func(t *testing.T) { - assert.NotPanics(t, func() { autog.Layout(graph.EdgeSlice(DotAbstract)) }) - }) -}