diff --git a/pkgs/sdk/server-ai/src/Graph/AgentGraphDefinition.cs b/pkgs/sdk/server-ai/src/Graph/AgentGraphDefinition.cs index eb73ddf2..c78c7973 100644 --- a/pkgs/sdk/server-ai/src/Graph/AgentGraphDefinition.cs +++ b/pkgs/sdk/server-ai/src/Graph/AgentGraphDefinition.cs @@ -94,12 +94,13 @@ public IReadOnlyList TerminalNodes() => public AiGraphTracker CreateTracker() => _createTracker(); /// - /// Performs a breadth-first traversal of the graph starting from the root node. - /// For each visited node, is called with the node and the - /// accumulated context dictionary. The return value of is - /// stored in the context under the node's key and passed to subsequent calls. - /// Cycle-safe: each node is visited at most once. + /// Visits each reachable node in topological order (predecessors first, root first). + /// Ties break by discovery order. Cycle-safe. /// + /// + /// receives plus results + /// from that node's reachable predecessors only. + /// public void Traverse( Func, object> fn, Dictionary initialContext = null) @@ -108,91 +109,143 @@ public void Traverse( if (root == null) return; var context = initialContext ?? new Dictionary(); + var (reachable, order) = ReachableAndDiscovery(root.Key); + + var indeg = reachable.ToDictionary(k => k, _ => 0); + foreach (var k in reachable) + { + foreach (var e in GetNode(k).Edges) + { + if (reachable.Contains(e.Key)) indeg[e.Key]++; + } + } + indeg[root.Key] = 0; var visited = new HashSet(); - var queue = new Queue(); - queue.Enqueue(root); - visited.Add(root.Key); + var results = new Dictionary(); + var ancestors = new Dictionary>(); - while (queue.Count > 0) + Dictionary Scoped(HashSet deps) { - var node = queue.Dequeue(); - var result = fn(node, context); - context[node.Key] = result; + var c = new Dictionary(context); + foreach (var k in deps) c[k] = results[k]; + return c; + } - foreach (var child in GetChildNodes(node.Key)) + while (visited.Count < reachable.Count) + { + var next = order.FirstOrDefault(k => !visited.Contains(k) && indeg[k] == 0) + ?? order.Where(k => !visited.Contains(k)).OrderBy(k => indeg[k]).First(); + + var anc = new HashSet(); + foreach (var parent in GetParentNodes(next)) { - if (visited.Add(child.Key)) - { - queue.Enqueue(child); - } + if (!visited.Contains(parent.Key)) continue; + anc.Add(parent.Key); + anc.UnionWith(ancestors[parent.Key]); + } + ancestors[next] = anc; + visited.Add(next); + + results[next] = fn(GetNode(next), Scoped(anc)); + foreach (var e in GetNode(next).Edges) + { + if (reachable.Contains(e.Key)) indeg[e.Key]--; } } } /// - /// Performs a reverse breadth-first traversal: starts from terminal nodes and - /// works upward toward the root. The root node is always processed last. - /// Cycle-safe: each node is visited at most once. + /// Visits each reachable node in reverse topological order (descendants first, + /// root last). Ties break by discovery order. Cycle-safe. /// + /// + /// receives plus results + /// from that node's reachable descendants only. + /// public void ReverseTraverse( Func, object> fn, Dictionary initialContext = null) { - if (_nodes.Count == 0) return; + var root = RootNode(); + if (root == null) return; + var rootKey = root.Key; var context = initialContext ?? new Dictionary(); + var (reachable, order) = ReachableAndDiscovery(rootKey); + + var outdeg = reachable.ToDictionary( + k => k, k => GetNode(k).Edges.Count(e => reachable.Contains(e.Key))); - var root = RootNode(); var visited = new HashSet(); - var queue = new Queue(); + var results = new Dictionary(); + var descendants = new Dictionary>(); - // Seed with terminal nodes (excluding root if it happens to be terminal and there are others) - foreach (var terminal in TerminalNodes()) + Dictionary Scoped(HashSet deps) { - if (root != null && terminal.Key == root.Key && _nodes.Count > 1) - { - continue; - } - if (visited.Add(terminal.Key)) - { - queue.Enqueue(terminal); - } + var c = new Dictionary(context); + foreach (var k in deps) c[k] = results[k]; + return c; } - while (queue.Count > 0) + bool NonRootRemaining() => reachable.Any(k => k != rootKey && !visited.Contains(k)); + while (NonRootRemaining()) { - var node = queue.Dequeue(); + var next = order.FirstOrDefault(k => k != rootKey && !visited.Contains(k) && outdeg[k] == 0) + ?? order.Where(k => k != rootKey && !visited.Contains(k)).OrderBy(k => outdeg[k]).First(); - // Defer root until the very end (unless it's the only node) - if (root != null && node.Key == root.Key && _nodes.Count > 1) + var desc = new HashSet(); + foreach (var e in GetNode(next).Edges) { - continue; + if (!reachable.Contains(e.Key) || !visited.Contains(e.Key)) continue; + desc.Add(e.Key); + desc.UnionWith(descendants[e.Key]); } + descendants[next] = desc; + visited.Add(next); + + results[next] = fn(GetNode(next), Scoped(desc)); + foreach (var parent in GetParentNodes(next)) + { + if (parent.Key != rootKey && reachable.Contains(parent.Key)) + outdeg[parent.Key]--; + } + } + + // Root last + var rootDeps = new HashSet(reachable.Where(k => k != rootKey)); + results[rootKey] = fn(root, Scoped(rootDeps)); + } - var result = fn(node, context); - context[node.Key] = result; + /// + /// Reachable nodes from and BFS discovery order + /// (declared edge order). Used as a topological tie-break. + /// + private (HashSet reachable, List order) ReachableAndDiscovery(string rootKey) + { + var reachable = new HashSet(); + var order = new List(); + var queue = new Queue(); + reachable.Add(rootKey); + order.Add(rootKey); + queue.Enqueue(rootKey); - foreach (var parent in GetParentNodes(node.Key)) + while (queue.Count > 0) + { + var key = queue.Dequeue(); + var node = GetNode(key); + if (node == null) continue; + foreach (var edge in node.Edges) { - if (root != null && parent.Key == root.Key) - { - // Don't enqueue root yet — process it last - continue; - } - if (visited.Add(parent.Key)) + if (GetNode(edge.Key) != null && reachable.Add(edge.Key)) { - queue.Enqueue(parent); + order.Add(edge.Key); + queue.Enqueue(edge.Key); } } } - // Process root last - if (root != null && _nodes.Count > 1 && visited.Count > 0) - { - var result = fn(root, context); - context[root.Key] = result; - } + return (reachable, order); } /// diff --git a/pkgs/sdk/server-ai/test/AgentGraphDefinitionTest.cs b/pkgs/sdk/server-ai/test/AgentGraphDefinitionTest.cs index 67bfc821..b6db263a 100644 --- a/pkgs/sdk/server-ai/test/AgentGraphDefinitionTest.cs +++ b/pkgs/sdk/server-ai/test/AgentGraphDefinitionTest.cs @@ -179,9 +179,9 @@ public void GetConfigAvailableOnDisabledGraph() Assert.Same(flagValue, disabled.GetConfig()); } - // Test 32: Traverse visits nodes BFS from root + // Test 32: Traverse visits nodes in topological order from root [Fact] - public void TraverseVisitsNodesInBfsOrder() + public void TraverseVisitsNodesInTopologicalOrder() { var graph = BuildEnabled(ThreeNodeFlagValue(), ThreeNodeConfigs()); @@ -229,7 +229,7 @@ public void CreateTrackerReturnsGraphTrackerWithGraphKey() Assert.Equal("my-graph-key", tracker.GetTrackData().GraphKey); } - // Test 35: Cycle-safe — pure cycle has no terminal nodes, so reverse traversal is a no-op + // Test 35: Cycle-safe — pure cycle visits all nodes, root last [Fact] public void ReverseTraverseIsCycleSafe() { @@ -261,8 +261,11 @@ public void ReverseTraverseIsCycleSafe() return null; }); - // Pure cycle has no terminal nodes — reverse traversal is a no-op per spec AIGRAPH 1.4 - Assert.Empty(visited); + Assert.Equal(3, visited.Count); + Assert.Equal("a", visited[visited.Count - 1]); + Assert.Contains("a", visited); + Assert.Contains("b", visited); + Assert.Contains("c", visited); } // Test 36: Cycle-safe — graph with cycles doesn't infinite loop @@ -299,6 +302,7 @@ public void TraverseIsCycleSafe() // Each node visited exactly once despite cycle Assert.Equal(3, visited.Count); + Assert.Equal("a", visited[0]); Assert.Contains("a", visited); Assert.Contains("b", visited); Assert.Contains("c", visited); @@ -361,20 +365,21 @@ public void BuildNodesSkipsKeysMissingFromConfigs() } [Fact] - public void TraversePassesContextBetweenNodes() + public void TraversePassesScopedResultsBetweenDependentNodes() { var graph = BuildEnabled(ThreeNodeFlagValue(), ThreeNodeConfigs()); - var ctx = new Dictionary(); + var seen = new Dictionary>(); graph.Traverse((node, context) => { - context[$"{node.Key}-visited"] = true; + seen[node.Key] = new Dictionary(context); return node.Key.ToUpper(); - }, ctx); + }); - Assert.Equal("AGENT-A", ctx["agent-a"]); - Assert.Equal("AGENT-B", ctx["agent-b"]); - Assert.Equal("AGENT-C", ctx["agent-c"]); + Assert.Empty(seen["agent-a"]); + Assert.Equal("AGENT-A", seen["agent-b"]["agent-a"]); + Assert.Equal("AGENT-A", seen["agent-c"]["agent-a"]); + Assert.Equal("AGENT-B", seen["agent-c"]["agent-b"]); } [Fact] @@ -422,4 +427,530 @@ public void GraphEdgeWithNoHandoffHasNullHandoff() var nodes = AgentGraphDefinition.BuildNodes(flagValue, ThreeNodeConfigs()); Assert.Null(nodes["agent-a"].Edges[0].Handoff); } + + // ----------------------------------------------------------------------- + // Topological parity fixtures (G1–G6) + // ----------------------------------------------------------------------- + + private static AgentGraphDefinition BuildGraph(string root, + Dictionary> edges) + { + var keys = new HashSet { root }; + foreach (var kv in edges) + { + keys.Add(kv.Key); + foreach (var e in kv.Value) keys.Add(e.Key); + } + var configs = keys.ToDictionary(k => k, k => MakeAgentConfig(k)); + var flagValue = new AgentGraphFlagValue + { + Root = root, + Edges = edges, + Meta = new LdMeta { Enabled = true } + }; + return BuildEnabled(flagValue, configs); + } + + private static List CollectOrder(AgentGraphDefinition graph, bool reverse) + { + var order = new List(); + if (reverse) + graph.ReverseTraverse((node, _) => { order.Add(node.Key); return null; }); + else + graph.Traverse((node, _) => { order.Add(node.Key); return null; }); + return order; + } + + private static void AssertExactContextKeys( + Dictionary ctx, IEnumerable expected) + { + var actual = ctx.Keys.OrderBy(k => k).ToArray(); + var expectedSorted = expected.OrderBy(k => k).ToArray(); + Assert.Equal(expectedSorted, actual); + } + + private static readonly Dictionary G2ForwardContext = + new Dictionary + { + ["a"] = Array.Empty(), + ["b"] = new[] { "a" }, + ["c"] = new[] { "a" }, + ["d"] = new[] { "a", "c" }, + ["e"] = new[] { "a", "b", "c", "d" } + }; + + private static readonly Dictionary G2ReverseContext = + new Dictionary + { + ["a"] = new[] { "b", "c", "d", "e" }, + ["b"] = new[] { "e" }, + ["c"] = new[] { "d", "e" }, + ["d"] = new[] { "e" }, + ["e"] = Array.Empty() + }; + + /// + /// Canonical G1–G6/G2b agent-graph traversal vectors, shared across the LaunchDarkly AI SDKs: + /// order plus exact traverse_context / reverse_traverse_context. + /// + public static IEnumerable Vectors() + { + yield return new object[] + { + "G1", + "a", + new Dictionary> + { + ["a"] = new[] { new GraphEdge("b", null) }, + ["b"] = new[] { new GraphEdge("c", null) } + }, + new[] { "a", "b", "c" }, + new[] { "c", "b", "a" }, + new Dictionary + { + ["a"] = Array.Empty(), + ["b"] = new[] { "a" }, + ["c"] = new[] { "a", "b" } + }, + new Dictionary + { + ["a"] = new[] { "b", "c" }, + ["b"] = new[] { "c" }, + ["c"] = Array.Empty() + } + }; + yield return new object[] + { + "G2", + "a", + new Dictionary> + { + ["a"] = new[] { new GraphEdge("b", null), new GraphEdge("c", null) }, + ["b"] = new[] { new GraphEdge("e", null) }, + ["c"] = new[] { new GraphEdge("d", null) }, + ["d"] = new[] { new GraphEdge("e", null) } + }, + new[] { "a", "b", "c", "d", "e" }, + new[] { "e", "b", "d", "c", "a" }, + G2ForwardContext, + G2ReverseContext + }; + yield return new object[] + { + "G2b", + "a", + new Dictionary> + { + ["a"] = new[] { new GraphEdge("c", null), new GraphEdge("b", null) }, + ["b"] = new[] { new GraphEdge("e", null) }, + ["c"] = new[] { new GraphEdge("d", null) }, + ["d"] = new[] { new GraphEdge("e", null) } + }, + new[] { "a", "c", "b", "d", "e" }, + new[] { "e", "b", "d", "c", "a" }, + G2ForwardContext, + G2ReverseContext + }; + yield return new object[] + { + "G3", + "a", + new Dictionary> + { + ["a"] = new[] { new GraphEdge("b", null), new GraphEdge("c", null) }, + ["b"] = new[] { new GraphEdge("d", null) }, + ["c"] = new[] { new GraphEdge("d", null) } + }, + new[] { "a", "b", "c", "d" }, + new[] { "d", "b", "c", "a" }, + new Dictionary + { + ["a"] = Array.Empty(), + ["b"] = new[] { "a" }, + ["c"] = new[] { "a" }, + ["d"] = new[] { "a", "b", "c" } + }, + new Dictionary + { + ["a"] = new[] { "b", "c", "d" }, + ["b"] = new[] { "d" }, + ["c"] = new[] { "d" }, + ["d"] = Array.Empty() + } + }; + yield return new object[] + { + "G4", + "a", + new Dictionary> + { + ["a"] = new[] { new GraphEdge("n", null) }, + ["n"] = new[] { new GraphEdge("m", null), new GraphEdge("t", null) }, + ["m"] = new[] { new GraphEdge("t", null) } + }, + new[] { "a", "n", "m", "t" }, + new[] { "t", "m", "n", "a" }, + new Dictionary + { + ["a"] = Array.Empty(), + ["n"] = new[] { "a" }, + ["m"] = new[] { "a", "n" }, + ["t"] = new[] { "a", "m", "n" } + }, + new Dictionary + { + ["a"] = new[] { "m", "n", "t" }, + ["n"] = new[] { "m", "t" }, + ["m"] = new[] { "t" }, + ["t"] = Array.Empty() + } + }; + yield return new object[] + { + "G5", + "a", + new Dictionary> + { + ["a"] = new[] { new GraphEdge("b", null), new GraphEdge("c", null) }, + ["b"] = new[] { new GraphEdge("d", null) } + }, + new[] { "a", "b", "c", "d" }, + new[] { "c", "d", "b", "a" }, + new Dictionary + { + ["a"] = Array.Empty(), + ["b"] = new[] { "a" }, + ["c"] = new[] { "a" }, + ["d"] = new[] { "a", "b" } + }, + new Dictionary + { + ["a"] = new[] { "b", "c", "d" }, + ["b"] = new[] { "d" }, + ["c"] = Array.Empty(), + ["d"] = Array.Empty() + } + }; + yield return new object[] + { + "G6", + "a", + new Dictionary> + { + ["a"] = new[] { new GraphEdge("b", null) }, + ["b"] = new[] { new GraphEdge("c", null) }, + ["c"] = new[] { new GraphEdge("b", null) } + }, + new[] { "a", "b", "c" }, + new[] { "b", "c", "a" }, + new Dictionary + { + ["a"] = Array.Empty(), + ["b"] = new[] { "a" }, + ["c"] = new[] { "a", "b" } + }, + new Dictionary + { + ["a"] = new[] { "b", "c" }, + ["b"] = Array.Empty(), + ["c"] = new[] { "b" } + } + }; + } + + [Theory] + [MemberData(nameof(Vectors))] + public void VectorTraversalOrderAndContext( + string id, + string root, + Dictionary> edges, + string[] traverse, + string[] reverseTraverse, + Dictionary forwardContext, + Dictionary reverseContext) + { + Assert.NotNull(id); + var graph = BuildGraph(root, edges); + + Assert.Equal(traverse, CollectOrder(graph, reverse: false)); + Assert.Equal(reverseTraverse, CollectOrder(graph, reverse: true)); + + graph.Traverse((node, ctx) => + { + AssertExactContextKeys(ctx, forwardContext[node.Key]); + return $"result-of-{node.Key}"; + }); + + graph.ReverseTraverse((node, ctx) => + { + AssertExactContextKeys(ctx, reverseContext[node.Key]); + return $"result-of-{node.Key}"; + }); + } + + [Fact] + public void G1_TraverseVisitsLinearChain() + { + var graph = BuildGraph("a", new Dictionary> + { + ["a"] = new[] { new GraphEdge("b", null) }, + ["b"] = new[] { new GraphEdge("c", null) } + }); + Assert.Equal(new[] { "a", "b", "c" }, CollectOrder(graph, reverse: false)); + } + + [Fact] + public void G1_ReverseTraverseVisitsLinearChain() + { + var graph = BuildGraph("a", new Dictionary> + { + ["a"] = new[] { new GraphEdge("b", null) }, + ["b"] = new[] { new GraphEdge("c", null) } + }); + Assert.Equal(new[] { "c", "b", "a" }, CollectOrder(graph, reverse: true)); + } + + [Fact] + public void G2_TraverseVisitsSkewedDiamond() + { + var graph = BuildGraph("a", new Dictionary> + { + ["a"] = new[] { new GraphEdge("b", null), new GraphEdge("c", null) }, + ["b"] = new[] { new GraphEdge("e", null) }, + ["c"] = new[] { new GraphEdge("d", null) }, + ["d"] = new[] { new GraphEdge("e", null) } + }); + Assert.Equal(new[] { "a", "b", "c", "d", "e" }, CollectOrder(graph, reverse: false)); + } + + [Fact] + public void G2_ReverseTraverseVisitsSkewedDiamond() + { + var graph = BuildGraph("a", new Dictionary> + { + ["a"] = new[] { new GraphEdge("b", null), new GraphEdge("c", null) }, + ["b"] = new[] { new GraphEdge("e", null) }, + ["c"] = new[] { new GraphEdge("d", null) }, + ["d"] = new[] { new GraphEdge("e", null) } + }); + Assert.Equal(new[] { "e", "b", "d", "c", "a" }, CollectOrder(graph, reverse: true)); + } + + [Fact] + public void G2b_TraverseKeepsELastWhenAEdgesReordered() + { + var graph = BuildGraph("a", new Dictionary> + { + ["a"] = new[] { new GraphEdge("c", null), new GraphEdge("b", null) }, + ["b"] = new[] { new GraphEdge("e", null) }, + ["c"] = new[] { new GraphEdge("d", null) }, + ["d"] = new[] { new GraphEdge("e", null) } + }); + var order = CollectOrder(graph, reverse: false); + Assert.Equal(new[] { "a", "c", "b", "d", "e" }, order); + Assert.True(order.IndexOf("d") < order.IndexOf("e")); + } + + [Fact] + public void G2b_ReverseTraverseKeepsEBeforeDWhenAEdgesReordered() + { + var graph = BuildGraph("a", new Dictionary> + { + ["a"] = new[] { new GraphEdge("c", null), new GraphEdge("b", null) }, + ["b"] = new[] { new GraphEdge("e", null) }, + ["c"] = new[] { new GraphEdge("d", null) }, + ["d"] = new[] { new GraphEdge("e", null) } + }); + var order = CollectOrder(graph, reverse: true); + Assert.Equal("e", order[0]); + Assert.True(order.IndexOf("e") < order.IndexOf("d")); + Assert.True(order.IndexOf("e") < order.IndexOf("b")); + Assert.Equal("a", order[order.Count - 1]); + } + + [Fact] + public void G3_TraverseVisitsSymmetricDiamond() + { + var graph = BuildGraph("a", new Dictionary> + { + ["a"] = new[] { new GraphEdge("b", null), new GraphEdge("c", null) }, + ["b"] = new[] { new GraphEdge("d", null) }, + ["c"] = new[] { new GraphEdge("d", null) } + }); + Assert.Equal(new[] { "a", "b", "c", "d" }, CollectOrder(graph, reverse: false)); + } + + [Fact] + public void G3_ReverseTraverseVisitsSymmetricDiamond() + { + var graph = BuildGraph("a", new Dictionary> + { + ["a"] = new[] { new GraphEdge("b", null), new GraphEdge("c", null) }, + ["b"] = new[] { new GraphEdge("d", null) }, + ["c"] = new[] { new GraphEdge("d", null) } + }); + Assert.Equal(new[] { "d", "b", "c", "a" }, CollectOrder(graph, reverse: true)); + } + + [Fact] + public void G4_TraverseVisitsNestedParent() + { + var graph = BuildGraph("a", new Dictionary> + { + ["a"] = new[] { new GraphEdge("n", null) }, + ["n"] = new[] { new GraphEdge("m", null), new GraphEdge("t", null) }, + ["m"] = new[] { new GraphEdge("t", null) } + }); + Assert.Equal(new[] { "a", "n", "m", "t" }, CollectOrder(graph, reverse: false)); + } + + [Fact] + public void G4_ReverseTraverseVisitsNestedParent() + { + var graph = BuildGraph("a", new Dictionary> + { + ["a"] = new[] { new GraphEdge("n", null) }, + ["n"] = new[] { new GraphEdge("m", null), new GraphEdge("t", null) }, + ["m"] = new[] { new GraphEdge("t", null) } + }); + Assert.Equal(new[] { "t", "m", "n", "a" }, CollectOrder(graph, reverse: true)); + } + + [Fact] + public void G5_TraverseVisitsMultiTerminal() + { + var graph = BuildGraph("a", new Dictionary> + { + ["a"] = new[] { new GraphEdge("b", null), new GraphEdge("c", null) }, + ["b"] = new[] { new GraphEdge("d", null) } + }); + Assert.Equal(new[] { "a", "b", "c", "d" }, CollectOrder(graph, reverse: false)); + } + + [Fact] + public void G5_ReverseTraverseVisitsMultiTerminal() + { + var graph = BuildGraph("a", new Dictionary> + { + ["a"] = new[] { new GraphEdge("b", null), new GraphEdge("c", null) }, + ["b"] = new[] { new GraphEdge("d", null) } + }); + Assert.Equal(new[] { "c", "d", "b", "a" }, CollectOrder(graph, reverse: true)); + } + + [Fact] + public void G6_TraverseVisitsCycleDeterministically() + { + var graph = BuildGraph("a", new Dictionary> + { + ["a"] = new[] { new GraphEdge("b", null) }, + ["b"] = new[] { new GraphEdge("c", null) }, + ["c"] = new[] { new GraphEdge("b", null) } + }); + var first = CollectOrder(graph, reverse: false); + var second = CollectOrder(graph, reverse: false); + Assert.Equal("a", first[0]); + Assert.Equal(3, first.Count); + Assert.Equal(new[] { "a", "b", "c" }, first.OrderBy(k => k).ToArray()); + Assert.Equal(first, second); + } + + [Fact] + public void G6_ReverseTraverseVisitsCycleDeterministically() + { + var graph = BuildGraph("a", new Dictionary> + { + ["a"] = new[] { new GraphEdge("b", null) }, + ["b"] = new[] { new GraphEdge("c", null) }, + ["c"] = new[] { new GraphEdge("b", null) } + }); + var first = CollectOrder(graph, reverse: true); + var second = CollectOrder(graph, reverse: true); + Assert.Equal("a", first[first.Count - 1]); + Assert.Equal(3, first.Count); + Assert.Equal(new[] { "a", "b", "c" }, first.OrderBy(k => k).ToArray()); + Assert.Equal(first, second); + Assert.Equal(new[] { "b", "c", "a" }, first); + } + + [Fact] + public void TraverseIncludesInitialContextAlongsideScopedPredecessors() + { + var graph = BuildGraph("a", new Dictionary> + { + ["a"] = new[] { new GraphEdge("b", null), new GraphEdge("c", null) }, + ["b"] = new[] { new GraphEdge("e", null) }, + ["c"] = new[] { new GraphEdge("d", null) }, + ["d"] = new[] { new GraphEdge("e", null) } + }); + + var expectedDeps = new Dictionary + { + ["a"] = Array.Empty(), + ["b"] = new[] { "a" }, + ["c"] = new[] { "a" }, + ["d"] = new[] { "a", "c" }, + ["e"] = new[] { "a", "b", "c", "d" } + }; + + graph.Traverse((node, ctx) => + { + var expectedKeys = new List(expectedDeps[node.Key]) { "seed" }; + AssertExactContextKeys(ctx, expectedKeys); + Assert.Equal("value", ctx["seed"]); + return $"result-of-{node.Key}"; + }, new Dictionary { ["seed"] = "value" }); + } + + [Fact] + public void TraverseVisitsSelfLoopOnce() + { + var graph = BuildGraph("a", new Dictionary> + { + ["a"] = new[] { new GraphEdge("a", null) } + }); + Assert.Equal(new[] { "a" }, CollectOrder(graph, reverse: false)); + } + + [Fact] + public void SelfLoopIsNotIncludedInOwnContext() + { + // a → b → b (self-loop on b) + var graph = BuildGraph("a", new Dictionary> + { + ["a"] = new[] { new GraphEdge("b", null) }, + ["b"] = new[] { new GraphEdge("b", null) } + }); + var initial = new Dictionary { ["seed"] = 1 }; + + graph.Traverse((node, ctx) => + { + if (node.Key == "b") + AssertExactContextKeys(ctx, new[] { "seed", "a" }); + return $"result-of-{node.Key}"; + }, initial); + + graph.ReverseTraverse((node, ctx) => + { + if (node.Key == "b") + AssertExactContextKeys(ctx, new[] { "seed" }); + if (node.Key == "a") + AssertExactContextKeys(ctx, new[] { "seed", "b" }); + return $"result-of-{node.Key}"; + }, initial); + } + + [Fact] + public void TraverseAndReverseTraverseAreDeterministic() + { + var graph = BuildGraph("a", new Dictionary> + { + ["a"] = new[] { new GraphEdge("b", null), new GraphEdge("c", null) }, + ["b"] = new[] { new GraphEdge("e", null) }, + ["c"] = new[] { new GraphEdge("d", null) }, + ["d"] = new[] { new GraphEdge("e", null) } + }); + Assert.Equal(CollectOrder(graph, reverse: false), CollectOrder(graph, reverse: false)); + Assert.Equal(CollectOrder(graph, reverse: true), CollectOrder(graph, reverse: true)); + } }