From 90e079b3138b0be6bb8a5b59eb8168ee5d7045fc Mon Sep 17 00:00:00 2001 From: Matt McCarthy Date: Tue, 28 Jul 2026 15:37:29 -0500 Subject: [PATCH 1/5] feat topological agent graph traversal --- .../src/Graph/AgentGraphDefinition.cs | 161 ++++--- .../test/AgentGraphDefinitionTest.cs | 393 +++++++++++++++++- 2 files changed, 488 insertions(+), 66 deletions(-) diff --git a/pkgs/sdk/server-ai/src/Graph/AgentGraphDefinition.cs b/pkgs/sdk/server-ai/src/Graph/AgentGraphDefinition.cs index eb73ddf2..8c35082a 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..e614e4dc 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,368 @@ 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); + } + + [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 G2_TraverseScopesContextToExactPredecessors() + { + 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 expected = 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) => + { + AssertExactContextKeys(ctx, expected[node.Key]); + if (node.Key == "b") Assert.False(ctx.ContainsKey("c")); + if (node.Key == "d") Assert.False(ctx.ContainsKey("b")); + return $"result-of-{node.Key}"; + }); + } + + [Fact] + public void G2_ReverseTraverseScopesContextToExactDescendants() + { + 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 expected = new Dictionary + { + ["a"] = new[] { "b", "c", "d", "e" }, + ["b"] = new[] { "e" }, + ["c"] = new[] { "d", "e" }, + ["d"] = new[] { "e" }, + ["e"] = Array.Empty() + }; + + graph.ReverseTraverse((node, ctx) => + { + AssertExactContextKeys(ctx, expected[node.Key]); + if (node.Key == "b" || node.Key == "d") + Assert.False(ctx.ContainsKey("c")); + return $"result-of-{node.Key}"; + }); + } + + [Fact] + public void G2b_TraverseContextScopingIndependentOfEdgeOrder() + { + 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 expected = 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) => + { + AssertExactContextKeys(ctx, expected[node.Key]); + return $"result-of-{node.Key}"; + }); + } + + [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 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)); + } } From 77012850bb378778f2c14812fefb9911014500a0 Mon Sep 17 00:00:00 2001 From: Matt McCarthy Date: Tue, 28 Jul 2026 16:07:43 -0500 Subject: [PATCH 2/5] fix: == instead of <= to match spec --- pkgs/sdk/server-ai/src/Graph/AgentGraphDefinition.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkgs/sdk/server-ai/src/Graph/AgentGraphDefinition.cs b/pkgs/sdk/server-ai/src/Graph/AgentGraphDefinition.cs index 8c35082a..c78c7973 100644 --- a/pkgs/sdk/server-ai/src/Graph/AgentGraphDefinition.cs +++ b/pkgs/sdk/server-ai/src/Graph/AgentGraphDefinition.cs @@ -134,7 +134,7 @@ Dictionary Scoped(HashSet deps) while (visited.Count < reachable.Count) { - var next = order.FirstOrDefault(k => !visited.Contains(k) && indeg[k] <= 0) + 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(); @@ -191,7 +191,7 @@ Dictionary Scoped(HashSet deps) bool NonRootRemaining() => reachable.Any(k => k != rootKey && !visited.Contains(k)); while (NonRootRemaining()) { - var next = order.FirstOrDefault(k => k != rootKey && !visited.Contains(k) && outdeg[k] <= 0) + 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(); var desc = new HashSet(); From 9e9019a8d9bf387f49957a68ba1ef62006bfd5b3 Mon Sep 17 00:00:00 2001 From: Matt McCarthy Date: Wed, 29 Jul 2026 11:58:40 -0500 Subject: [PATCH 3/5] fix(AIGRAPH): assert exact scoped context for graph tests --- .../test/AgentGraphDefinitionTest.cs | 304 +++++++++++++----- 1 file changed, 219 insertions(+), 85 deletions(-) diff --git a/pkgs/sdk/server-ai/test/AgentGraphDefinitionTest.cs b/pkgs/sdk/server-ai/test/AgentGraphDefinitionTest.cs index e614e4dc..4381112c 100644 --- a/pkgs/sdk/server-ai/test/AgentGraphDefinitionTest.cs +++ b/pkgs/sdk/server-ai/test/AgentGraphDefinitionTest.cs @@ -469,6 +469,225 @@ private static void AssertExactContextKeys( 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 vectors from sdk-specs test-vectors/vectors.json: + /// 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() { @@ -654,91 +873,6 @@ public void G6_ReverseTraverseVisitsCycleDeterministically() Assert.Equal(new[] { "b", "c", "a" }, first); } - [Fact] - public void G2_TraverseScopesContextToExactPredecessors() - { - 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 expected = 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) => - { - AssertExactContextKeys(ctx, expected[node.Key]); - if (node.Key == "b") Assert.False(ctx.ContainsKey("c")); - if (node.Key == "d") Assert.False(ctx.ContainsKey("b")); - return $"result-of-{node.Key}"; - }); - } - - [Fact] - public void G2_ReverseTraverseScopesContextToExactDescendants() - { - 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 expected = new Dictionary - { - ["a"] = new[] { "b", "c", "d", "e" }, - ["b"] = new[] { "e" }, - ["c"] = new[] { "d", "e" }, - ["d"] = new[] { "e" }, - ["e"] = Array.Empty() - }; - - graph.ReverseTraverse((node, ctx) => - { - AssertExactContextKeys(ctx, expected[node.Key]); - if (node.Key == "b" || node.Key == "d") - Assert.False(ctx.ContainsKey("c")); - return $"result-of-{node.Key}"; - }); - } - - [Fact] - public void G2b_TraverseContextScopingIndependentOfEdgeOrder() - { - 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 expected = 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) => - { - AssertExactContextKeys(ctx, expected[node.Key]); - return $"result-of-{node.Key}"; - }); - } - [Fact] public void TraverseIncludesInitialContextAlongsideScopedPredecessors() { From 46fd70a0ed86e1fa1a24a97bf9c5075de212cbb1 Mon Sep 17 00:00:00 2001 From: Matt McCarthy Date: Wed, 29 Jul 2026 12:31:29 -0500 Subject: [PATCH 4/5] test: assert self-loop excluded from own context --- .../test/AgentGraphDefinitionTest.cs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/pkgs/sdk/server-ai/test/AgentGraphDefinitionTest.cs b/pkgs/sdk/server-ai/test/AgentGraphDefinitionTest.cs index 4381112c..801da65e 100644 --- a/pkgs/sdk/server-ai/test/AgentGraphDefinitionTest.cs +++ b/pkgs/sdk/server-ai/test/AgentGraphDefinitionTest.cs @@ -912,6 +912,34 @@ public void TraverseVisitsSelfLoopOnce() 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() { From 04bbbe9aa85a900555e69e07dab57738ce1ced06 Mon Sep 17 00:00:00 2001 From: Matt McCarthy Date: Wed, 29 Jul 2026 17:00:17 -0500 Subject: [PATCH 5/5] chore: drop sdk spec reference --- pkgs/sdk/server-ai/test/AgentGraphDefinitionTest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkgs/sdk/server-ai/test/AgentGraphDefinitionTest.cs b/pkgs/sdk/server-ai/test/AgentGraphDefinitionTest.cs index 801da65e..b6db263a 100644 --- a/pkgs/sdk/server-ai/test/AgentGraphDefinitionTest.cs +++ b/pkgs/sdk/server-ai/test/AgentGraphDefinitionTest.cs @@ -490,7 +490,7 @@ private static void AssertExactContextKeys( }; /// - /// Canonical G1–G6/G2b vectors from sdk-specs test-vectors/vectors.json: + /// 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()