diff --git a/graph.go b/graph.go index b799c48..e59211f 100644 --- a/graph.go +++ b/graph.go @@ -129,9 +129,10 @@ func (m *edgeMap) values() []Edge { if m == nil { return nil } - keys := jsObjectKeyOrder(append([]string(nil), m.order...)) - out := make([]Edge, 0, len(keys)) - for _, id := range keys { + // Canonical edge IDs always contain edgeKeyDelim, so none is a JS + // array-index key. Their property order is already their insertion order. + out := make([]Edge, 0, len(m.order)) + for _, id := range m.order { out = append(out, m.items[id]) } return out @@ -397,15 +398,32 @@ func (g *Graph) Successors(v string) []string { return nil } func (g *Graph) Neighbors(v string) []string { - seen := map[string]bool{} - var out []string - for _, list := range [][]string{g.Predecessors(v), g.Successors(v)} { - for _, w := range list { - if !seen[w] { - seen[w] = true + preds, sucs := g.preds[v], g.sucs[v] + n := 0 + if preds != nil { + n += len(preds.order) + } + if sucs != nil { + n += len(sucs.order) + } + if n == 0 { + return nil + } + out := make([]string, 0, n) + if preds != nil { + out = append(out, preds.order...) + jsObjectKeyOrder(out) + } + // Each counter already contains unique neighbors. Only successors that + // also occur in the predecessor counter need to be removed from the union. + start := len(out) + if sucs != nil { + for _, w := range sucs.order { + if preds == nil || preds.count[w] == 0 { out = append(out, w) } } + jsObjectKeyOrder(out[start:]) } return out } @@ -656,7 +674,44 @@ func (g *Graph) OutEdges(v string, w ...string) []Edge { return out } func (g *Graph) NodeEdges(v string, w ...string) []Edge { - return append(g.InEdges(v, w...), g.OutEdges(v, w...)...) + in, out := g.in[v], g.out[v] + if in == nil && out == nil { + if g.HasNode(v) { + return []Edge{} + } + return nil + } + n := 0 + if in != nil { + n += len(in.order) + } + if out != nil { + n += len(out.order) + } + edges := make([]Edge, 0, n) + filter := "" + if len(w) > 0 { + filter = w[0] + } + // Match graphlib's incoming-then-outgoing order, including duplicate + // self-edges and its empty-string endpoint meaning "no filter". + if in != nil { + for _, id := range in.order { + e := in.items[id] + if filter == "" || e.V == filter { + edges = append(edges, e) + } + } + } + if out != nil { + for _, id := range out.order { + e := out.items[id] + if filter == "" || e.W == filter { + edges = append(edges, e) + } + } + } + return edges } func edgeArgsToID(directed bool, v, w string, name *string) string { @@ -682,6 +737,20 @@ func edgeArgsToObj(directed bool, v, w string, name *string) Edge { } func jsStringGreater(a, b string) bool { + // Layout-generated IDs and D2's numeric IDs are ASCII. Compare that common + // prefix without allocating UTF-16 copies; retain JS code-unit ordering + // when either string reaches a non-ASCII byte. + i := 0 + for i < len(a) && i < len(b) && a[i] < 0x80 && b[i] < 0x80 { + if a[i] != b[i] { + return a[i] > b[i] + } + i++ + } + if i == len(a) || i == len(b) { + return len(a) > len(b) + } + a, b = a[i:], b[i:] a16, b16 := utf16.Encode([]rune(a)), utf16.Encode([]rune(b)) for i := 0; i < len(a16) && i < len(b16); i++ { if a16[i] != b16[i] { @@ -787,8 +856,10 @@ func dfs(g *Graph, starts []string, post bool) []string { if !post { out = append(out, v) } - next := g.Successors(v) - if !g.directed { + var next []string + if g.directed { + next = g.Successors(v) + } else { next = g.Neighbors(v) } for _, w := range next { diff --git a/graph_adjacency_test.go b/graph_adjacency_test.go new file mode 100644 index 0000000..98eeef2 --- /dev/null +++ b/graph_adjacency_test.go @@ -0,0 +1,159 @@ +package dagro + +import ( + "math/rand" + "reflect" + "testing" + "unicode/utf16" +) + +// These helpers freeze v0.2.0's adjacency enumeration, including the optional +// endpoint's empty-string behavior and self-edges appearing in both halves. +func originalEdgeValues(m *edgeMap) []Edge { + if m == nil { + return nil + } + keys := jsObjectKeyOrder(append([]string(nil), m.order...)) + out := make([]Edge, 0, len(keys)) + for _, id := range keys { + out = append(out, m.items[id]) + } + return out +} + +func TestJSStringGreaterMatchesUTF16(t *testing.T) { + strings := []string{"", "0", "10", "a", "a\x00", "a\x7f", "a\u0080", "a\ufffd", "a\ue000", "a\U00010000", "a\U0010ffff", "\xff", "a\xc0\x80"} + rng := rand.New(rand.NewSource(1039)) + for i := 0; i < 300; i++ { + b := make([]byte, rng.Intn(20)) + for j := range b { + b[j] = byte(rng.Intn(256)) + } + strings = append(strings, string(b)) + } + original := func(a, b string) bool { + a16, b16 := utf16.Encode([]rune(a)), utf16.Encode([]rune(b)) + for i := 0; i < len(a16) && i < len(b16); i++ { + if a16[i] != b16[i] { + return a16[i] > b16[i] + } + } + return len(a16) > len(b16) + } + for _, a := range strings { + for _, b := range strings { + if got, want := jsStringGreater(a, b), original(a, b); got != want { + t.Fatalf("jsStringGreater(%q, %q) = %v; want %v", a, b, got, want) + } + } + } +} + +func originalNodeEdges(g *Graph, v string, w ...string) []Edge { + edges := func(m *edgeMap, incoming bool) []Edge { + if m == nil { + if g.HasNode(v) { + return []Edge{} + } + return nil + } + all := originalEdgeValues(m) + if len(w) == 0 || w[0] == "" { + return all + } + out := all[:0] + for _, e := range all { + if incoming && e.V == w[0] || !incoming && e.W == w[0] { + out = append(out, e) + } + } + return out + } + return append(edges(g.in[v], true), edges(g.out[v], false)...) +} + +func TestAdjacencyMatchesOriginalAfterMutations(t *testing.T) { + ids := []string{"", "10", "2", "0", "01", "4294967294", "4294967295", "a", "z", "\U00010000", "\ue000", "a\x01b", "b\x01c"} + for _, undirected := range []bool{false, true} { + for _, multigraph := range []bool{false, true} { + g := NewGraph(GraphOptions{Undirected: undirected, Multigraph: multigraph}) + rng := rand.New(rand.NewSource(7231)) + for step := 0; step < 150; step++ { + v, w := ids[rng.Intn(len(ids))], ids[rng.Intn(len(ids))] + if step%5 == 0 { + g.RemoveNode(v) + } else if step%3 == 0 { + g.RemoveEdgeByArgs(v, w) + } else if multigraph && step%2 == 0 { + g.SetEdge(v, w, nil, ids[rng.Intn(len(ids))]) + } else { + g.SetEdge(v, w) + } + g.SetNode("isolated") + if got, want := g.Edges(), originalEdgeValues(g.edgeObjs); !reflect.DeepEqual(got, want) { + t.Fatalf("step %d: edges = %#v; want %#v", step, got, want) + } + for _, node := range append(append([]string(nil), ids...), "isolated", "missing") { + var neighbors []string + seen := map[string]bool{} + for _, list := range [][]string{g.Predecessors(node), g.Successors(node)} { + for _, v := range list { + if !seen[v] { + seen[v] = true + neighbors = append(neighbors, v) + } + } + } + if got := g.Neighbors(node); !reflect.DeepEqual(got, neighbors) { + t.Fatalf("step %d: Neighbors(%q) = %#v; want %#v", step, node, got, neighbors) + } + for _, filter := range [][]string{nil, {""}, {v}, {w}, {"missing"}} { + got, want := g.NodeEdges(node, filter...), originalNodeEdges(g, node, filter...) + if !reflect.DeepEqual(got, want) { + t.Fatalf("step %d: NodeEdges(%q, %q) = %#v; want %#v", step, node, filter, got, want) + } + if len(got) > 0 { + got[0] = Edge{V: "mutated"} + if !reflect.DeepEqual(g.NodeEdges(node, filter...), want) { + t.Fatal("returned edges alias graph storage") + } + } + } + } + } + } + } +} + +func TestTreeEdgeMatchesOriginal(t *testing.T) { + ids := []string{"z", "10", "2", "", "\ue000", "\U00010000", "a\x01b", "b\x01c", "a", "c"} + for _, undirected := range []bool{false, true} { + for _, multigraph := range []bool{false, true} { + g := NewGraph(GraphOptions{Undirected: undirected, Multigraph: multigraph}) + for i := 1; i < len(ids); i++ { + g.SetEdge(ids[i/2], ids[i]) + if multigraph { + g.SetEdge(ids[i/2], ids[i], nil, "named") + } + } + for _, u := range ids { + for _, v := range ids { + want := originalNodeEdges(g, u, v) + var got Edge + panicked := false + func() { + defer func() { panicked = recover() != nil }() + got = treeEdge(g, u, v) + }() + if len(want) == 0 { + if !panicked { + t.Fatalf("treeEdge(%q, %q) should panic", u, v) + } + } else if panicked || got != want[0] { + t.Fatalf("treeEdge(%q, %q) = %#v (panic %v); want %#v", u, v, got, panicked, want[0]) + } + } + } + } + } +} diff --git a/network_simplex.go b/network_simplex.go index 500beec..f18de1c 100644 --- a/network_simplex.go +++ b/network_simplex.go @@ -7,8 +7,9 @@ func networkSimplex(input *Graph) { g := simplify(input) longestPath(g) t := feasibleTree(g) - initLowLimValues(t) - initCutValues(t, g) + order := newRankTreeOrder(t) + order.assignLowLim(t) + assignCutValues(t, g, order.post) for { e, ok := leaveEdge(t) @@ -19,12 +20,15 @@ func networkSimplex(input *Graph) { if !ok { panic("dagro: networkSimplex could not find an entering edge") } - exchangeEdges(t, g, e, f) + exchangeEdges(t, g, e, f, order) } } func initCutValues(t, g *Graph) { - vs := postorder(t, t.Nodes()) + assignCutValues(t, g, postorder(t, t.Nodes())) +} + +func assignCutValues(t, g *Graph, vs []string) { if len(vs) > 0 { vs = vs[:len(vs)-1] } @@ -55,29 +59,38 @@ func calcCutValue(t, g *Graph, child string) float64 { } cutValue := num(asAttrs(g.Edge(graphEdge)), "weight") - for _, e := range g.NodeEdges(child) { - isOutEdge := e.V == child - other := e.V - if isOutEdge { - other = e.W - } - if other == parent { + // The input graph is not mutated while computing cuts. Walk its incident + // edge maps directly, in NodeEdges order, without copying both lists on + // every tree-edge exchange. + for _, incident := range [2]*edgeMap{g.in[child], g.out[child]} { + if incident == nil { continue } + for _, id := range incident.order { + e := incident.items[id] + isOutEdge := e.V == child + other := e.V + if isOutEdge { + other = e.W + } + if other == parent { + continue + } - pointsToHead := isOutEdge == childIsTail - otherWeight := num(asAttrs(g.Edge(e)), "weight") - if pointsToHead { - cutValue += otherWeight - } else { - cutValue -= otherWeight - } - if isTreeEdge(t, child, other) { - otherCutValue := num(asAttrs(t.Edge(treeEdge(t, child, other))), "cutvalue") + pointsToHead := isOutEdge == childIsTail + otherWeight := num(asAttrs(g.Edge(e)), "weight") if pointsToHead { - cutValue -= otherCutValue + cutValue += otherWeight } else { - cutValue += otherCutValue + cutValue -= otherWeight + } + if isTreeEdge(t, child, other) { + otherCutValue := num(asAttrs(t.Edge(treeEdge(t, child, other))), "cutvalue") + if pointsToHead { + cutValue -= otherCutValue + } else { + cutValue += otherCutValue + } } } } @@ -94,13 +107,39 @@ func initLowLimValues(tree *Graph, roots ...string) { dfsAssignLowLim(tree, map[string]bool{}, 1, root, "") } -func dfsAssignLowLim(tree *Graph, visited map[string]bool, nextLim float64, v, parent string) float64 { +// A feasible tree stays connected and keeps the same nodes through all edge +// exchanges. Its low/lim traversal is also the traversal used to calculate +// cuts and update ranks. Retain both orders instead of traversing it three +// times, and reuse the traversal storage across exchanges. +type rankTreeOrder struct { + root string + visited map[string]bool + pre, post []string +} + +func newRankTreeOrder(tree *Graph) *rankTreeOrder { + return &rankTreeOrder{ + root: tree.Nodes()[0], visited: make(map[string]bool, tree.NodeCount()), + pre: make([]string, 0, tree.NodeCount()), post: make([]string, 0, tree.NodeCount()), + } +} + +func (order *rankTreeOrder) assignLowLim(tree *Graph) { + clear(order.visited) + order.pre, order.post = order.pre[:0], order.post[:0] + dfsAssignLowLim(tree, order.visited, 1, order.root, "", order) +} + +func dfsAssignLowLim(tree *Graph, visited map[string]bool, nextLim float64, v, parent string, orders ...*rankTreeOrder) float64 { low := nextLim label := asAttrs(tree.Node(v)) visited[v] = true + if len(orders) > 0 { + orders[0].pre = append(orders[0].pre, v) + } for _, w := range tree.Neighbors(v) { if !visited[w] { - nextLim = dfsAssignLowLim(tree, visited, nextLim, w, v) + nextLim = dfsAssignLowLim(tree, visited, nextLim, w, v, orders...) } } @@ -112,11 +151,15 @@ func dfsAssignLowLim(tree *Graph, visited map[string]bool, nextLim float64, v, p } else { delete(label, "parent") } + if len(orders) > 0 { + orders[0].post = append(orders[0].post, v) + } return nextLim } func leaveEdge(tree *Graph) (Edge, bool) { - for _, e := range tree.Edges() { + for _, id := range tree.edgeObjs.order { + e := tree.edgeObjs.items[id] if num(asAttrs(tree.Edge(e)), "cutvalue") < 0 { return e, true } @@ -142,7 +185,8 @@ func enterEdge(t, g *Graph, edge Edge) (Edge, bool) { var best Edge bestSlack := 0.0 found := false - for _, candidate := range g.Edges() { + for _, id := range g.edgeObjs.order { + candidate := g.edgeObjs.items[id] vDescendant := isDescendant(asAttrs(t.Node(candidate.V)), tailLabel) wDescendant := isDescendant(asAttrs(t.Node(candidate.W)), tailLabel) if flip != vDescendant || flip == wDescendant { @@ -156,15 +200,26 @@ func enterEdge(t, g *Graph, edge Edge) (Edge, bool) { return best, found } -func exchangeEdges(t, g *Graph, e, f Edge) { +func exchangeEdges(t, g *Graph, e, f Edge, orders ...*rankTreeOrder) { t.RemoveEdge(e) t.SetEdge(f.V, f.W, Attrs{}) + if len(orders) > 0 { + order := orders[0] + order.assignLowLim(t) + assignCutValues(t, g, order.post) + updateRanksWithOrder(t, g, order.pre) + return + } initLowLimValues(t) initCutValues(t, g) updateRanks(t, g) } func updateRanks(t, g *Graph) { + updateRanksWithOrder(t, g, nil) +} + +func updateRanksWithOrder(t, g *Graph, vs []string) { root, found := "", false for _, v := range t.Nodes() { label := asAttrs(g.Node(v)) @@ -178,7 +233,11 @@ func updateRanks(t, g *Graph) { return } - vs := preorder(t, []string{root}) + // The JS hook permits a root selected from input labels to differ from + // the low/lim root. Preserve that traversal when the roots differ. + if len(vs) == 0 || vs[0] != root { + vs = preorder(t, []string{root}) + } if len(vs) > 0 { vs = vs[1:] } @@ -212,6 +271,16 @@ func isDescendant(vLabel, rootLabel Attrs) bool { } func treeEdge(tree *Graph, u, v string) Edge { + // Feasible trees are simple and undirected. Locate their canonical edge + // directly instead of allocating and scanning both adjacency lists. + // Keep NodeEdges' empty-string filter behavior for the JS test hook. + if !tree.directed && !tree.multigraph && v != "" { + if e, ok := tree.edgeObjs.items[edgeArgsToID(false, u, v, nil)]; ok && + (e.V == u && e.W == v || e.V == v && e.W == u) { + return e + } + panic("dagro: expected tree edge") + } for _, e := range tree.NodeEdges(u, v) { return e } diff --git a/rank_tree_order_test.go b/rank_tree_order_test.go new file mode 100644 index 0000000..71ad3a7 --- /dev/null +++ b/rank_tree_order_test.go @@ -0,0 +1,103 @@ +package dagro + +import ( + "bytes" + "encoding/json" + "math" + "math/rand" + "testing" +) + +func TestRankTreeOrderMatchesSeparateTraversals(t *testing.T) { + ids := []string{"z", "10", "2", "0", "01", "a", "b", "c", "d", "\ue000", "\U00010000", "100", "5", "9", "22"} + totalExchanges := 0 + for seed := int64(0); seed < 100; seed++ { + build := func() *Graph { + rng := rand.New(rand.NewSource(seed)) + g := NewGraph() + for _, id := range ids { + g.SetNode(id, Attrs{}) + } + for i := 1; i < len(ids); i++ { + g.SetEdge(ids[rng.Intn(i)], ids[i], Attrs{"weight": float64(rng.Intn(100)) / 7, "minlen": float64(1 + rng.Intn(4))}) + } + for i, id := range ids { + for j := i + 1; j < len(ids); j++ { + if rng.Intn(6) == 0 { + g.SetEdge(id, ids[j], Attrs{"weight": float64(rng.Intn(100)) / 7, "minlen": float64(1 + rng.Intn(4))}) + } + } + } + return g + } + g, wantG := build(), build() + longestPath(g) + longestPath(wantG) + tree, wantTree := feasibleTree(g), feasibleTree(wantG) + order := newRankTreeOrder(tree) + order.assignLowLim(tree) + assignCutValues(tree, g, order.post) + initLowLimValues(wantTree) + initCutValues(wantTree, wantG) + compare := func(a, b *Graph) { + // JSON also distinguishes negative zero, unlike reflect.DeepEqual. + got, err := json.Marshal([]any{a.Nodes(), a.Edges(), a.nodes, a.edgeLabels}) + if err != nil { + t.Fatal(err) + } + want, err := json.Marshal([]any{b.Nodes(), b.Edges(), b.nodes, b.edgeLabels}) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, want) { + t.Fatalf("seed %d: graph changed\ngot %s\nwant %s", seed, got, want) + } + } + for exchanges := 0; ; exchanges++ { + compare(tree, wantTree) + compare(g, wantG) + e, ok := leaveEdge(wantTree) + if !ok { + break + } + if exchanges > 1000 { + t.Fatalf("seed %d: excessive exchanges", seed) + } + f, ok := enterEdge(wantTree, wantG, e) + if !ok { + t.Fatalf("seed %d: no entering edge", seed) + } + totalExchanges++ + exchangeEdges(tree, g, e, f, order) + exchangeEdges(wantTree, wantG, e, f) + } + } + if totalExchanges < 20 { + t.Fatalf("only %d exchanges exercised", totalExchanges) + } + t.Logf("compared %d tree exchanges across 100 graphs", totalExchanges) +} + +func TestRankTreeOrderPreservesDifferentRankRoot(t *testing.T) { + build := func() (*Graph, *Graph) { + g := newRankTestGraph(true). + SetNode("a", Attrs{"rank": 0.0, "parent": "input-root"}). + SetNode("b", Attrs{"rank": 2.0}). + SetNode("c", Attrs{"rank": 5.0}). + SetPath([]string{"a", "b", "c"}) + tree := newRankTestTree().SetPath([]string{"a", "b", "c"}) + initLowLimValues(tree) + return g, tree + } + g, tree := build() + wantG, wantTree := build() + order := newRankTreeOrder(tree) + order.assignLowLim(tree) + updateRanksWithOrder(tree, g, order.pre) + updateRanks(wantTree, wantG) + for _, id := range g.Nodes() { + if got, want := num(asAttrs(g.Node(id)), "rank"), num(asAttrs(wantG.Node(id)), "rank"); math.Float64bits(got) != math.Float64bits(want) { + t.Fatalf("node %q rank = %v; want %v", id, got, want) + } + } +}