From 8d8882d66aff546ec4363881074f0fc0aacf8e04 Mon Sep 17 00:00:00 2001 From: Matt McCarthy Date: Tue, 28 Jul 2026 15:37:50 -0500 Subject: [PATCH 1/5] feat: topological agent graph traversal --- .../sdk/server/ai/AgentGraphDefinition.java | 264 +++++++++++++--- .../server/ai/AgentGraphDefinitionTest.java | 292 +++++++++++++----- 2 files changed, 442 insertions(+), 114 deletions(-) diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AgentGraphDefinition.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AgentGraphDefinition.java index 1dceadc7..f9e337ca 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AgentGraphDefinition.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AgentGraphDefinition.java @@ -2,6 +2,7 @@ import com.launchdarkly.sdk.server.ai.internal.AgentGraphFlagValue; +import java.util.AbstractMap; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -23,8 +24,9 @@ * {@link #getConfig()} and {@link #createTracker()} remain meaningful, so callers can still inspect * the raw flag value and fire graph-level usage events for a disabled graph. *

- * Traversal methods ({@link #traverse} and {@link #reverseTraverse}) are BFS-based and - * cycle-safe: each node is visited at most once. + * Traversal ({@link #traverse}, {@link #reverseTraverse}) visits each reachable node once in + * topological order (predecessors-first or descendants-first), deterministically and cycle-safe. + * Each visitor sees only the initial context plus that node's dependency results. *

* This class is thread-safe. All returned collections are unmodifiable. */ @@ -158,16 +160,16 @@ public AIGraphTracker createTracker() { } /** - * Performs a BFS traversal of the graph starting from the root node. + * Topological traversal from the root (predecessors-first; root first). *

- * For each node visited, {@code fn} is called with the node and the mutable context map. The - * return value of {@code fn} is stored in the context map under the node's key, making it - * available to subsequently visited nodes. Each node is visited exactly once (cycle-safe). + * A node runs only after all reachable predecessors. Ties break by discovery order (BFS from + * root, declared edge order). Cycle-safe: each reachable node is visited once. *

- * This is a no-op when the graph is disabled or the root node is absent. + * {@code fn} receives a fresh map of the initial {@code ctx} plus that node's predecessor + * results only. {@code ctx} itself is not mutated. No-op if disabled or root is absent. * - * @param fn the visitor function; receives the current node and the context map - * @param ctx the mutable context map; values from earlier nodes are available to later ones + * @param fn visitor; node and dependency-scoped context + * @param ctx initial context template (global scratch); not written with node results */ public void traverse(BiFunction, Object> fn, Map ctx) { @@ -176,33 +178,75 @@ public void traverse(BiFunction, Object> fn, return; } + Map.Entry, List> rd = reachableAndDiscovery(root.getKey()); + Set reachable = rd.getKey(); + List order = rd.getValue(); + + Map indeg = new HashMap<>(); + for (String k : reachable) { + indeg.put(k, 0); + } + for (String k : reachable) { + AgentGraphNode node = getNode(k); + if (node == null) { + continue; + } + for (GraphEdge e : node.getEdges()) { + if (reachable.contains(e.getKey())) { + indeg.merge(e.getKey(), 1, Integer::sum); + } + } + } + indeg.put(root.getKey(), 0); + Set visited = new HashSet<>(); - Queue queue = new LinkedList<>(); - visited.add(root.getKey()); - queue.add(root); + Map results = new HashMap<>(); + Map> ancestors = new HashMap<>(); + while (visited.size() < reachable.size()) { + String next = firstReady(order, visited, indeg); + if (next == null) { + next = lowestDegree(order, visited, indeg); + } + visited.add(next); - while (!queue.isEmpty()) { - AgentGraphNode node = queue.poll(); - Object result = fn.apply(node, ctx); - ctx.put(node.getKey(), result); + Set anc = new HashSet<>(); + for (AgentGraphNode parent : getParentNodes(next)) { + String pk = parent.getKey(); + if (!visited.contains(pk) || !reachable.contains(pk) || pk.equals(next)) { + continue; + } + anc.add(pk); + Set parentAnc = ancestors.get(pk); + if (parentAnc != null) { + anc.addAll(parentAnc); + } + } + ancestors.put(next, anc); + + AgentGraphNode nextNode = getNode(next); + results.put(next, fn.apply(nextNode, scopedCtx(ctx, results, anc))); - for (AgentGraphNode child : getChildNodes(node.getKey())) { - if (visited.add(child.getKey())) { - queue.add(child); + if (nextNode != null) { + for (GraphEdge e : nextNode.getEdges()) { + if (reachable.contains(e.getKey())) { + indeg.merge(e.getKey(), -1, Integer::sum); + } } } } } /** - * Performs a reverse BFS traversal of the graph, starting from terminal nodes and working - * upward toward the root. + * Reverse topological traversal (descendants-first; root last). + *

+ * A node runs only after all reachable descendants. Ties break by discovery order. Cycle-safe, + * including graphs with no terminals. Each reachable node is visited once. *

- * The root node is always processed last. Each node is visited exactly once (cycle-safe). This - * is a no-op when the graph is disabled or there are no terminal nodes. + * {@code fn} receives a fresh map of the initial {@code ctx} plus that node's descendant + * results only. {@code ctx} itself is not mutated. No-op if disabled or root is absent. * - * @param fn the visitor function; receives the current node and the context map - * @param ctx the mutable context map; values from earlier nodes are available to later ones + * @param fn visitor; node and dependency-scoped context + * @param ctx initial context template (global scratch); not written with node results */ public void reverseTraverse(BiFunction, Object> fn, Map ctx) { @@ -210,34 +254,172 @@ public void reverseTraverse(BiFunction, Obje if (root == null) { return; } + String rootKey = root.getKey(); + + Map.Entry, List> rd = reachableAndDiscovery(rootKey); + Set reachable = rd.getKey(); + List order = rd.getValue(); + + Map outdeg = new HashMap<>(); + for (String k : reachable) { + int d = 0; + AgentGraphNode node = getNode(k); + if (node != null) { + for (GraphEdge e : node.getEdges()) { + if (reachable.contains(e.getKey())) { + d++; + } + } + } + outdeg.put(k, d); + } Set visited = new HashSet<>(); - Queue queue = new LinkedList<>(); + Map results = new HashMap<>(); + Map> descendants = new HashMap<>(); + while (hasNonRootRemaining(reachable, visited, rootKey)) { + String next = firstReadyNonRoot(order, visited, outdeg, rootKey); + if (next == null) { + next = lowestDegreeNonRoot(order, visited, outdeg, rootKey); + } + visited.add(next); - // Seed from terminals, excluding root (it will be processed last). - for (AgentGraphNode terminal : terminalNodes()) { - if (!terminal.getKey().equals(root.getKey()) && visited.add(terminal.getKey())) { - queue.add(terminal); + Set desc = new HashSet<>(); + AgentGraphNode nextNode = getNode(next); + if (nextNode != null) { + for (GraphEdge e : nextNode.getEdges()) { + String ck = e.getKey(); + if (!reachable.contains(ck) || !visited.contains(ck)) { + continue; + } + desc.add(ck); + Set childDesc = descendants.get(ck); + if (childDesc != null) { + desc.addAll(childDesc); + } + } + } + descendants.put(next, desc); + results.put(next, fn.apply(nextNode, scopedCtx(ctx, results, desc))); + + for (AgentGraphNode parent : getParentNodes(next)) { + String pk = parent.getKey(); + if (!pk.equals(rootKey) && reachable.contains(pk)) { + outdeg.merge(pk, -1, Integer::sum); + } } } - while (!queue.isEmpty()) { - AgentGraphNode node = queue.poll(); - Object result = fn.apply(node, ctx); - ctx.put(node.getKey(), result); + Set rootDeps = new HashSet<>(); + for (String k : reachable) { + if (!k.equals(rootKey)) { + rootDeps.add(k); + } + } + results.put(rootKey, fn.apply(root, scopedCtx(ctx, results, rootDeps))); + } - for (AgentGraphNode parent : getParentNodes(node.getKey())) { - if (!parent.getKey().equals(root.getKey()) && visited.add(parent.getKey())) { - queue.add(parent); + /** Reachable set and discovery order (BFS from root, declared edge order). */ + private Map.Entry, List> reachableAndDiscovery(String rootKey) { + Set reachable = new HashSet<>(); + List order = new ArrayList<>(); + Queue queue = new LinkedList<>(); + reachable.add(rootKey); + order.add(rootKey); + queue.add(rootKey); + while (!queue.isEmpty()) { + String key = queue.poll(); + AgentGraphNode node = getNode(key); + if (node == null) { + continue; + } + for (GraphEdge edge : node.getEdges()) { + if (getNode(edge.getKey()) != null && reachable.add(edge.getKey())) { + order.add(edge.getKey()); + queue.add(edge.getKey()); } } } + return new AbstractMap.SimpleEntry<>(reachable, order); + } + + /** Copy of {@code initial} with {@code results} entries for {@code deps} overlaid. */ + private static Map scopedCtx( + Map initial, Map results, Set deps) { + Map out = new HashMap<>(initial); + for (String k : deps) { + out.put(k, results.get(k)); + } + return out; + } + + private static String firstReady( + List order, Set visited, Map degree) { + for (String k : order) { + if (!visited.contains(k) && degree.get(k) != null && degree.get(k) == 0) { + return k; + } + } + return null; + } + + private static String lowestDegree( + List order, Set visited, Map degree) { + String best = null; + int bestDeg = Integer.MAX_VALUE; + for (String k : order) { + if (visited.contains(k)) { + continue; + } + Integer d = degree.get(k); + int deg = d == null ? 0 : d; + if (best == null || deg < bestDeg) { + best = k; + bestDeg = deg; + } + } + return best; + } + + private static String firstReadyNonRoot( + List order, Set visited, Map degree, String rootKey) { + for (String k : order) { + if (k.equals(rootKey) || visited.contains(k)) { + continue; + } + if (degree.get(k) != null && degree.get(k) == 0) { + return k; + } + } + return null; + } - // Process root last (whether or not it was encountered as a parent above). - if (visited.add(root.getKey())) { - Object result = fn.apply(root, ctx); - ctx.put(root.getKey(), result); + private static String lowestDegreeNonRoot( + List order, Set visited, Map degree, String rootKey) { + String best = null; + int bestDeg = Integer.MAX_VALUE; + for (String k : order) { + if (k.equals(rootKey) || visited.contains(k)) { + continue; + } + Integer d = degree.get(k); + int deg = d == null ? 0 : d; + if (best == null || deg < bestDeg) { + best = k; + bestDeg = deg; + } + } + return best; + } + + private static boolean hasNonRootRemaining( + Set reachable, Set visited, String rootKey) { + for (String k : reachable) { + if (!k.equals(rootKey) && !visited.contains(k)) { + return true; + } } + return false; } /** diff --git a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/AgentGraphDefinitionTest.java b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/AgentGraphDefinitionTest.java index 1b199cac..41af50ea 100644 --- a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/AgentGraphDefinitionTest.java +++ b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/AgentGraphDefinitionTest.java @@ -1,9 +1,9 @@ package com.launchdarkly.sdk.server.ai; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.empty; -import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.notNullValue; import static org.hamcrest.Matchers.nullValue; @@ -298,35 +298,61 @@ public void createTrackerReturnsTrackerWhenEnabled() { assertThat(graph.createTracker(), is(notNullValue())); } + // ---- traversal helpers ---------------------------------------------------- + + private static List visitOrder( + AgentGraphDefinition graph, boolean reverse, Map initialCtx) { + List visited = new ArrayList<>(); + BiFunction, Object> fn = (node, ctx) -> { + visited.add(node.getKey()); + return node.getKey() + "_result"; + }; + if (reverse) { + graph.reverseTraverse(fn, initialCtx); + } else { + graph.traverse(fn, initialCtx); + } + return visited; + } + + private static Map> captureContextKeys( + AgentGraphDefinition graph, boolean reverse, Map initialCtx) { + Map> keysByNode = new LinkedHashMap<>(); + BiFunction, Object> fn = (node, ctx) -> { + keysByNode.put(node.getKey(), new HashSet<>(ctx.keySet())); + return node.getKey() + "_result"; + }; + if (reverse) { + graph.reverseTraverse(fn, initialCtx); + } else { + graph.traverse(fn, initialCtx); + } + return keysByNode; + } + // ---- traverse ------------------------------------------------------------- @Test public void traverseVisitsAllNodesFromRoot() { - // a -> b -> c AgentGraphDefinition graph = buildEnabled("a", new String[][]{{"a", "b"}, {"b", "c"}}, "a", "b", "c"); - - List visited = new ArrayList<>(); - BiFunction, Object> fn = (node, ctx) -> { - visited.add(node.getKey()); - return node.getKey(); - }; - graph.traverse(fn, new HashMap<>()); - assertThat(visited, containsInAnyOrder("a", "b", "c")); - // Root must be first - assertThat(visited.get(0), is("a")); + List visited = visitOrder(graph, false, new HashMap()); + assertThat(visited, contains("a", "b", "c")); } @Test - public void traverseStoresResultsInContext() { + public void traverseDoesNotMutateCallerContextWithNodeResults() { AgentGraphDefinition graph = buildEnabled("a", new String[][]{{"a", "b"}}, "a", "b"); Map ctx = new HashMap<>(); - BiFunction, Object> fn = (node, c) -> node.getKey() + "_result"; - graph.traverse(fn, ctx); + ctx.put("seed", "value"); + Map> keysByNode = captureContextKeys(graph, false, ctx); - assertThat(ctx.get("a"), is("a_result")); - assertThat(ctx.get("b"), is("b_result")); + // Caller map is only the initial-context template; node results are not written back. + assertThat(ctx.keySet(), containsInAnyOrder("seed")); + assertThat(keysByNode.get("a"), containsInAnyOrder("seed")); + assertThat(keysByNode.get("b"), containsInAnyOrder("seed", "a")); + assertThat(keysByNode.get("b").contains("b"), is(false)); } @Test @@ -341,49 +367,32 @@ public void traverseIsNoOpWhenDisabled() { @Test public void traverseHandlesCyclesSafely() { - // Manually build a cyclic graph: a -> b -> a - LDClientInterface client = mock(LDClientInterface.class); - Map cfgs = configs("a", "b"); - List aEdges = Collections.singletonList(new GraphEdge("b", null)); - List bEdges = Collections.singletonList(new GraphEdge("a", null)); - Map nodes = new HashMap<>(); - nodes.put("a", new AgentGraphNode("a", cfgs.get("a"), aEdges)); - nodes.put("b", new AgentGraphNode("b", cfgs.get("b"), bEdges)); - nodes = Collections.unmodifiableMap(nodes); - - AgentGraphFlagValue fv = flagValue("a", new String[][]{{"a", "b"}, {"b", "a"}}); - AgentGraphDefinition graph = new AgentGraphDefinition(fv, nodes, true, null); + AgentGraphDefinition graph = buildEnabled("a", + new String[][]{{"a", "b"}, {"b", "a"}}, "a", "b"); - List visited = new ArrayList<>(); - graph.traverse((node, ctx) -> { visited.add(node.getKey()); return null; }, new HashMap<>()); - assertThat(visited.size(), is(2)); // each node visited exactly once + List visited = visitOrder(graph, false, new HashMap()); + assertThat(visited.size(), is(2)); + assertThat(visited.get(0), is("a")); + assertThat(new HashSet<>(visited), containsInAnyOrder("a", "b")); } // ---- reverseTraverse ------------------------------------------------------ @Test public void reverseTraverseProcessesRootLast() { - // a -> b -> c AgentGraphDefinition graph = buildEnabled("a", new String[][]{{"a", "b"}, {"b", "c"}}, "a", "b", "c"); - List visited = new ArrayList<>(); - graph.reverseTraverse((node, ctx) -> { visited.add(node.getKey()); return null; }, new HashMap<>()); - - // c is terminal (seeded first), root "a" is last - assertThat(visited.get(visited.size() - 1), is("a")); - assertThat(visited.contains("b"), is(true)); - assertThat(visited.contains("c"), is(true)); + List visited = visitOrder(graph, true, new HashMap()); + assertThat(visited, contains("c", "b", "a")); } @Test public void reverseTraverseVisitsAllNodes() { - // a -> b, a -> c (c and b are terminals) AgentGraphDefinition graph = buildEnabled("a", new String[][]{{"a", "b"}, {"a", "c"}}, "a", "b", "c"); - List visited = new ArrayList<>(); - graph.reverseTraverse((node, ctx) -> { visited.add(node.getKey()); return null; }, new HashMap<>()); + List visited = visitOrder(graph, true, new HashMap()); assertThat(visited, containsInAnyOrder("a", "b", "c")); assertThat(visited.get(visited.size() - 1), is("a")); } @@ -392,30 +401,20 @@ public void reverseTraverseVisitsAllNodes() { public void reverseTraverseSingleNodeGraph() { AgentGraphDefinition graph = buildEnabled("a", null, "a"); - List visited = new ArrayList<>(); - graph.reverseTraverse((node, ctx) -> { visited.add(node.getKey()); return null; }, new HashMap<>()); - assertThat(visited, containsInAnyOrder("a")); + List visited = visitOrder(graph, true, new HashMap()); + assertThat(visited, contains("a")); } @Test public void reverseTraverseHandlesCyclesSafely() { - Map cfgs = configs("a", "b"); - List aEdges = Collections.singletonList(new GraphEdge("b", null)); - List bEdges = Collections.singletonList(new GraphEdge("a", null)); - Map nodes = new HashMap<>(); - nodes.put("a", new AgentGraphNode("a", cfgs.get("a"), aEdges)); - nodes.put("b", new AgentGraphNode("b", cfgs.get("b"), bEdges)); - nodes = Collections.unmodifiableMap(nodes); - - AgentGraphFlagValue fv = flagValue("a", new String[][]{{"a", "b"}, {"b", "a"}}); - AgentGraphDefinition graph = new AgentGraphDefinition(fv, nodes, true, null); + AgentGraphDefinition graph = buildEnabled("a", + new String[][]{{"a", "b"}, {"b", "a"}}, "a", "b"); - List visited = new ArrayList<>(); - graph.reverseTraverse((node, ctx) -> { visited.add(node.getKey()); return null; }, new HashMap<>()); - // No infinite loop; in a pure cycle neither node is terminal, so no seeds are added — - // only root is processed in the final "root last" block. - assertThat(visited.size() <= 2, is(true)); - assertThat(visited.size() >= 1, is(true)); + List visited = visitOrder(graph, true, new HashMap()); + // Cycle-safe: every reachable node visited exactly once; root last. + assertThat(visited.size(), is(2)); + assertThat(visited.get(visited.size() - 1), is("a")); + assertThat(new HashSet<>(visited), containsInAnyOrder("a", "b")); } @Test @@ -432,17 +431,13 @@ public void reverseTraverseIsNoOpWhenDisabled() { @Test public void traverseDiamondGraph() { - // root -> a, root -> b; a -> sink, b -> sink AgentGraphDefinition graph = buildEnabled("root", new String[][]{{"root", "a"}, {"root", "b"}, {"a", "sink"}, {"b", "sink"}}, "root", "a", "b", "sink"); - List visited = new ArrayList<>(); - graph.traverse((node, ctx) -> { visited.add(node.getKey()); return null; }, new HashMap<>()); - // root first, sink visited only once + List visited = visitOrder(graph, false, new HashMap()); assertThat(visited.get(0), is("root")); - assertThat(visited.size(), is(4)); - assertThat(new HashSet<>(visited).size(), is(4)); // all unique + assertThat(visited, contains("root", "a", "b", "sink")); } @Test @@ -451,11 +446,162 @@ public void reverseTraverseDiamondGraph() { new String[][]{{"root", "a"}, {"root", "b"}, {"a", "sink"}, {"b", "sink"}}, "root", "a", "b", "sink"); - List visited = new ArrayList<>(); - graph.reverseTraverse((node, ctx) -> { visited.add(node.getKey()); return null; }, new HashMap<>()); - // root last, sink visited once + List visited = visitOrder(graph, true, new HashMap()); assertThat(visited.get(visited.size() - 1), is("root")); - assertThat(visited.size(), is(4)); - assertThat(new HashSet<>(visited).size(), is(4)); + assertThat(visited, contains("sink", "a", "b", "root")); + } + + // ---- G1–G6 parity fixtures ------------------------------------------------- + + @Test + public void g1LinearExactOrder() { + AgentGraphDefinition graph = buildEnabled("a", + new String[][]{{"a", "b"}, {"b", "c"}}, "a", "b", "c"); + assertThat(visitOrder(graph, false, new HashMap()), contains("a", "b", "c")); + assertThat(visitOrder(graph, true, new HashMap()), contains("c", "b", "a")); + } + + @Test + public void g2SkewedDiamondExactOrder() { + // a→b, a→c, c→d, d→e, b→e + AgentGraphDefinition graph = buildEnabled("a", + new String[][]{{"a", "b"}, {"a", "c"}, {"c", "d"}, {"d", "e"}, {"b", "e"}}, + "a", "b", "c", "d", "e"); + assertThat(visitOrder(graph, false, new HashMap()), + contains("a", "b", "c", "d", "e")); + assertThat(visitOrder(graph, true, new HashMap()), + contains("e", "b", "d", "c", "a")); + } + + @Test + public void g2EdgeOrderIndependence() { + // Same as G2 but a's edges declared [c, b] + AgentGraphDefinition graph = buildEnabled("a", + new String[][]{{"a", "c"}, {"a", "b"}, {"c", "d"}, {"d", "e"}, {"b", "e"}}, + "a", "b", "c", "d", "e"); + List forward = visitOrder(graph, false, new HashMap()); + assertThat(forward, contains("a", "c", "b", "d", "e")); + assertThat(forward.indexOf("e") > forward.indexOf("d"), is(true)); + } + + @Test + public void g3SymmetricDiamondExactOrder() { + AgentGraphDefinition graph = buildEnabled("a", + new String[][]{{"a", "b"}, {"a", "c"}, {"b", "d"}, {"c", "d"}}, + "a", "b", "c", "d"); + assertThat(visitOrder(graph, false, new HashMap()), + contains("a", "b", "c", "d")); + assertThat(visitOrder(graph, true, new HashMap()), + contains("d", "b", "c", "a")); + } + + @Test + public void g4NestedParentExactOrder() { + AgentGraphDefinition graph = buildEnabled("a", + new String[][]{{"a", "n"}, {"n", "m"}, {"n", "t"}, {"m", "t"}}, + "a", "n", "m", "t"); + assertThat(visitOrder(graph, false, new HashMap()), + contains("a", "n", "m", "t")); + assertThat(visitOrder(graph, true, new HashMap()), + contains("t", "m", "n", "a")); + } + + @Test + public void g5MultiTerminalExactOrder() { + AgentGraphDefinition graph = buildEnabled("a", + new String[][]{{"a", "b"}, {"a", "c"}, {"b", "d"}}, + "a", "b", "c", "d"); + assertThat(visitOrder(graph, false, new HashMap()), + contains("a", "b", "c", "d")); + assertThat(visitOrder(graph, true, new HashMap()), + contains("c", "d", "b", "a")); + } + + @Test + public void g6CycleExactOrder() { + // a→b, b→c, c→b + AgentGraphDefinition graph = buildEnabled("a", + new String[][]{{"a", "b"}, {"b", "c"}, {"c", "b"}}, + "a", "b", "c"); + List forward = visitOrder(graph, false, new HashMap()); + List reverse = visitOrder(graph, true, new HashMap()); + assertThat(forward, contains("a", "b", "c")); + assertThat(reverse, contains("b", "c", "a")); + } + + @Test + public void g2ExactContextScopingForward() { + AgentGraphDefinition graph = buildEnabled("a", + new String[][]{{"a", "b"}, {"a", "c"}, {"c", "d"}, {"d", "e"}, {"b", "e"}}, + "a", "b", "c", "d", "e"); + Map initial = new HashMap<>(); + initial.put("seed", 1); + Map> keys = captureContextKeys(graph, false, initial); + + assertThat(keys.get("a"), containsInAnyOrder("seed")); + assertThat(keys.get("b"), containsInAnyOrder("seed", "a")); + assertThat(keys.get("c"), containsInAnyOrder("seed", "a")); + assertThat(keys.get("d"), containsInAnyOrder("seed", "a", "c")); + assertThat(keys.get("e"), containsInAnyOrder("seed", "a", "b", "c", "d")); + // Parallel-branch leak must not occur + assertThat(keys.get("b").contains("c"), is(false)); + assertThat(keys.get("d").contains("c"), is(true)); // d's ancestor + assertThat(keys.get("d").contains("b"), is(false)); + } + + @Test + public void g2ExactContextScopingReverse() { + AgentGraphDefinition graph = buildEnabled("a", + new String[][]{{"a", "b"}, {"a", "c"}, {"c", "d"}, {"d", "e"}, {"b", "e"}}, + "a", "b", "c", "d", "e"); + Map initial = new HashMap<>(); + initial.put("seed", 1); + Map> keys = captureContextKeys(graph, true, initial); + + assertThat(keys.get("e"), containsInAnyOrder("seed")); + assertThat(keys.get("b"), containsInAnyOrder("seed", "e")); + assertThat(keys.get("d"), containsInAnyOrder("seed", "e")); + assertThat(keys.get("c"), containsInAnyOrder("seed", "d", "e")); + assertThat(keys.get("a"), containsInAnyOrder("seed", "b", "c", "d", "e")); + assertThat(keys.get("b").contains("c"), is(false)); + assertThat(keys.get("d").contains("c"), is(false)); + } + + @Test + public void g2ContextScopingIndependentOfEdgeOrder() { + AgentGraphDefinition graph = buildEnabled("a", + new String[][]{{"a", "c"}, {"a", "b"}, {"c", "d"}, {"d", "e"}, {"b", "e"}}, + "a", "b", "c", "d", "e"); + Map> keys = captureContextKeys(graph, false, new HashMap()); + assertThat(keys.get("b").contains("c"), is(false)); + assertThat(keys.get("d").contains("b"), is(false)); + assertThat(keys.get("e"), containsInAnyOrder("a", "b", "c", "d")); + } + + @Test + public void traversalIsDeterministicAcrossRuns() { + AgentGraphDefinition graph = buildEnabled("a", + new String[][]{{"a", "b"}, {"a", "c"}, {"c", "d"}, {"d", "e"}, {"b", "e"}}, + "a", "b", "c", "d", "e"); + List firstFwd = visitOrder(graph, false, new HashMap()); + List firstRev = visitOrder(graph, true, new HashMap()); + for (int i = 0; i < 20; i++) { + assertThat(visitOrder(graph, false, new HashMap()), is(firstFwd)); + assertThat(visitOrder(graph, true, new HashMap()), is(firstRev)); + } + } + + @Test + public void seededInitialContextVisibleToEveryNode() { + AgentGraphDefinition graph = buildEnabled("a", + new String[][]{{"a", "b"}, {"b", "c"}}, "a", "b", "c"); + Map initial = new HashMap<>(); + initial.put("provider", "handle"); + Map> fwd = captureContextKeys(graph, false, initial); + Map> rev = captureContextKeys(graph, true, initial); + for (String key : new String[]{"a", "b", "c"}) { + assertThat(fwd.get(key).contains("provider"), is(true)); + assertThat(rev.get(key).contains("provider"), is(true)); + } } } From 797ca0589c64e17e4e7216b3dcf62dbb98ad0c43 Mon Sep 17 00:00:00 2001 From: Matt McCarthy Date: Tue, 28 Jul 2026 16:09:52 -0500 Subject: [PATCH 2/5] fix: exclude self-loops from traversal context deps --- .../sdk/server/ai/AgentGraphDefinition.java | 8 +++++--- .../server/ai/AgentGraphDefinitionTest.java | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AgentGraphDefinition.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AgentGraphDefinition.java index f9e337ca..4fc83a3f 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AgentGraphDefinition.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AgentGraphDefinition.java @@ -207,12 +207,12 @@ public void traverse(BiFunction, Object> fn, if (next == null) { next = lowestDegree(order, visited, indeg); } - visited.add(next); + // Accumulate deps before marking visited so a self-loop does not count as its own ancestor. Set anc = new HashSet<>(); for (AgentGraphNode parent : getParentNodes(next)) { String pk = parent.getKey(); - if (!visited.contains(pk) || !reachable.contains(pk) || pk.equals(next)) { + if (!visited.contains(pk) || !reachable.contains(pk)) { continue; } anc.add(pk); @@ -222,6 +222,7 @@ public void traverse(BiFunction, Object> fn, } } ancestors.put(next, anc); + visited.add(next); AgentGraphNode nextNode = getNode(next); results.put(next, fn.apply(nextNode, scopedCtx(ctx, results, anc))); @@ -282,8 +283,8 @@ public void reverseTraverse(BiFunction, Obje if (next == null) { next = lowestDegreeNonRoot(order, visited, outdeg, rootKey); } - visited.add(next); + // Accumulate deps before marking visited so a self-loop does not count as its own descendant. Set desc = new HashSet<>(); AgentGraphNode nextNode = getNode(next); if (nextNode != null) { @@ -300,6 +301,7 @@ public void reverseTraverse(BiFunction, Obje } } descendants.put(next, desc); + visited.add(next); results.put(next, fn.apply(nextNode, scopedCtx(ctx, results, desc))); for (AgentGraphNode parent : getParentNodes(next)) { diff --git a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/AgentGraphDefinitionTest.java b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/AgentGraphDefinitionTest.java index 41af50ea..2b523cfb 100644 --- a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/AgentGraphDefinitionTest.java +++ b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/AgentGraphDefinitionTest.java @@ -417,6 +417,24 @@ public void reverseTraverseHandlesCyclesSafely() { assertThat(new HashSet<>(visited), containsInAnyOrder("a", "b")); } + @Test + public void selfLoopIsNotIncludedInOwnContext() { + // a → b → b (self-loop on b) + AgentGraphDefinition graph = buildEnabled("a", + new String[][]{{"a", "b"}, {"b", "b"}}, "a", "b"); + Map initial = new HashMap<>(); + initial.put("seed", 1); + + Map> fwd = captureContextKeys(graph, false, initial); + assertThat(fwd.get("b"), containsInAnyOrder("seed", "a")); + assertThat(fwd.get("b").contains("b"), is(false)); + + Map> rev = captureContextKeys(graph, true, initial); + assertThat(rev.get("b"), containsInAnyOrder("seed")); + assertThat(rev.get("b").contains("b"), is(false)); + assertThat(rev.get("a"), containsInAnyOrder("seed", "b")); + } + @Test public void reverseTraverseIsNoOpWhenDisabled() { AgentGraphDefinition graph = new AgentGraphDefinition( From 71fe52d4f46c5ea84386a234596328a306382a87 Mon Sep 17 00:00:00 2001 From: Matt McCarthy Date: Wed, 29 Jul 2026 11:57:49 -0500 Subject: [PATCH 3/5] fix(AIGRAPH): assert exact scoped context for graph tests --- .../server/ai/AgentGraphDefinitionTest.java | 179 +++++++++++++----- 1 file changed, 135 insertions(+), 44 deletions(-) diff --git a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/AgentGraphDefinitionTest.java b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/AgentGraphDefinitionTest.java index 2b523cfb..e2874950 100644 --- a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/AgentGraphDefinitionTest.java +++ b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/AgentGraphDefinitionTest.java @@ -4,6 +4,7 @@ import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.notNullValue; import static org.hamcrest.Matchers.nullValue; @@ -16,6 +17,7 @@ import com.launchdarkly.sdk.server.interfaces.LDClientInterface; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; @@ -330,6 +332,120 @@ private static Map> captureContextKeys( return keysByNode; } + /** Canonical order + exact-context vector (empty initial context). */ + private static final class Vector { + final String id; + final String root; + final String[][] edges; + final String[] nodeKeys; + final List fwdOrder; + final List revOrder; + final Map> fwdCtx; + final Map> revCtx; + + Vector( + String id, + String root, + String[][] edges, + String[] nodeKeys, + List fwdOrder, + List revOrder, + Map> fwdCtx, + Map> revCtx) { + this.id = id; + this.root = root; + this.edges = edges; + this.nodeKeys = nodeKeys; + this.fwdOrder = fwdOrder; + this.revOrder = revOrder; + this.fwdCtx = fwdCtx; + this.revCtx = revCtx; + } + } + + private static List lists(String... keys) { + return Arrays.asList(keys); + } + + private static Set set(String... keys) { + return new HashSet<>(Arrays.asList(keys)); + } + + /** Builds a node→dependency-key map from alternating node, deps pairs. */ + @SafeVarargs + private static Map> ctx(Object... nodeAndDeps) { + Map> m = new HashMap<>(); + for (int i = 0; i < nodeAndDeps.length; i += 2) { + @SuppressWarnings("unchecked") + Set deps = (Set) nodeAndDeps[i + 1]; + m.put((String) nodeAndDeps[i], deps); + } + return m; + } + + private static final List VECTORS = Arrays.asList( + new Vector( + "G1", "a", + new String[][]{{"a", "b"}, {"b", "c"}}, + new String[]{"a", "b", "c"}, + lists("a", "b", "c"), + lists("c", "b", "a"), + ctx("a", set(), "b", set("a"), "c", set("a", "b")), + ctx("a", set("b", "c"), "b", set("c"), "c", set())), + new Vector( + "G2", "a", + new String[][]{{"a", "b"}, {"a", "c"}, {"c", "d"}, {"d", "e"}, {"b", "e"}}, + new String[]{"a", "b", "c", "d", "e"}, + lists("a", "b", "c", "d", "e"), + lists("e", "b", "d", "c", "a"), + ctx("a", set(), "b", set("a"), "c", set("a"), "d", set("a", "c"), + "e", set("a", "b", "c", "d")), + ctx("a", set("b", "c", "d", "e"), "b", set("e"), "c", set("d", "e"), + "d", set("e"), "e", set())), + new Vector( + "G2b", "a", + new String[][]{{"a", "c"}, {"a", "b"}, {"c", "d"}, {"d", "e"}, {"b", "e"}}, + new String[]{"a", "b", "c", "d", "e"}, + lists("a", "c", "b", "d", "e"), + lists("e", "b", "d", "c", "a"), + ctx("a", set(), "b", set("a"), "c", set("a"), "d", set("a", "c"), + "e", set("a", "b", "c", "d")), + ctx("a", set("b", "c", "d", "e"), "b", set("e"), "c", set("d", "e"), + "d", set("e"), "e", set())), + new Vector( + "G3", "a", + new String[][]{{"a", "b"}, {"a", "c"}, {"b", "d"}, {"c", "d"}}, + new String[]{"a", "b", "c", "d"}, + lists("a", "b", "c", "d"), + lists("d", "b", "c", "a"), + ctx("a", set(), "b", set("a"), "c", set("a"), "d", set("a", "b", "c")), + ctx("a", set("b", "c", "d"), "b", set("d"), "c", set("d"), "d", set())), + new Vector( + "G4", "a", + new String[][]{{"a", "n"}, {"n", "m"}, {"n", "t"}, {"m", "t"}}, + new String[]{"a", "n", "m", "t"}, + lists("a", "n", "m", "t"), + lists("t", "m", "n", "a"), + ctx("a", set(), "n", set("a"), "m", set("a", "n"), "t", set("a", "m", "n")), + ctx("a", set("m", "n", "t"), "n", set("m", "t"), "m", set("t"), "t", set())), + new Vector( + "G5", "a", + new String[][]{{"a", "b"}, {"a", "c"}, {"b", "d"}}, + new String[]{"a", "b", "c", "d"}, + lists("a", "b", "c", "d"), + lists("c", "d", "b", "a"), + ctx("a", set(), "b", set("a"), "c", set("a"), "d", set("a", "b")), + ctx("a", set("b", "c", "d"), "b", set("d"), "c", set(), "d", set())), + new Vector( + "G6", "a", + new String[][]{{"a", "b"}, {"b", "c"}, {"c", "b"}}, + new String[]{"a", "b", "c"}, + lists("a", "b", "c"), + lists("b", "c", "a"), + ctx("a", set(), "b", set("a"), "c", set("a", "b")), + ctx("a", set("b", "c"), "b", set(), "c", set("b"))) + ); + // ---- traverse ------------------------------------------------------------- @Test @@ -548,52 +664,27 @@ public void g6CycleExactOrder() { } @Test - public void g2ExactContextScopingForward() { - AgentGraphDefinition graph = buildEnabled("a", - new String[][]{{"a", "b"}, {"a", "c"}, {"c", "d"}, {"d", "e"}, {"b", "e"}}, - "a", "b", "c", "d", "e"); - Map initial = new HashMap<>(); - initial.put("seed", 1); - Map> keys = captureContextKeys(graph, false, initial); - - assertThat(keys.get("a"), containsInAnyOrder("seed")); - assertThat(keys.get("b"), containsInAnyOrder("seed", "a")); - assertThat(keys.get("c"), containsInAnyOrder("seed", "a")); - assertThat(keys.get("d"), containsInAnyOrder("seed", "a", "c")); - assertThat(keys.get("e"), containsInAnyOrder("seed", "a", "b", "c", "d")); - // Parallel-branch leak must not occur - assertThat(keys.get("b").contains("c"), is(false)); - assertThat(keys.get("d").contains("c"), is(true)); // d's ancestor - assertThat(keys.get("d").contains("b"), is(false)); - } - - @Test - public void g2ExactContextScopingReverse() { - AgentGraphDefinition graph = buildEnabled("a", - new String[][]{{"a", "b"}, {"a", "c"}, {"c", "d"}, {"d", "e"}, {"b", "e"}}, - "a", "b", "c", "d", "e"); - Map initial = new HashMap<>(); - initial.put("seed", 1); - Map> keys = captureContextKeys(graph, true, initial); + public void vectorTraversalOrderAndContextParity() { + for (Vector v : VECTORS) { + AgentGraphDefinition graph = buildEnabled(v.root, v.edges, v.nodeKeys); + Map empty = new HashMap<>(); - assertThat(keys.get("e"), containsInAnyOrder("seed")); - assertThat(keys.get("b"), containsInAnyOrder("seed", "e")); - assertThat(keys.get("d"), containsInAnyOrder("seed", "e")); - assertThat(keys.get("c"), containsInAnyOrder("seed", "d", "e")); - assertThat(keys.get("a"), containsInAnyOrder("seed", "b", "c", "d", "e")); - assertThat(keys.get("b").contains("c"), is(false)); - assertThat(keys.get("d").contains("c"), is(false)); - } + assertThat(v.id + " forward order", + visitOrder(graph, false, empty), equalTo(v.fwdOrder)); + assertThat(v.id + " reverse order", + visitOrder(graph, true, empty), equalTo(v.revOrder)); - @Test - public void g2ContextScopingIndependentOfEdgeOrder() { - AgentGraphDefinition graph = buildEnabled("a", - new String[][]{{"a", "c"}, {"a", "b"}, {"c", "d"}, {"d", "e"}, {"b", "e"}}, - "a", "b", "c", "d", "e"); - Map> keys = captureContextKeys(graph, false, new HashMap()); - assertThat(keys.get("b").contains("c"), is(false)); - assertThat(keys.get("d").contains("b"), is(false)); - assertThat(keys.get("e"), containsInAnyOrder("a", "b", "c", "d")); + Map> fwd = captureContextKeys(graph, false, empty); + for (Map.Entry> e : v.fwdCtx.entrySet()) { + assertThat(v.id + " forward ctx @" + e.getKey(), + fwd.get(e.getKey()), equalTo(e.getValue())); + } + Map> rev = captureContextKeys(graph, true, empty); + for (Map.Entry> e : v.revCtx.entrySet()) { + assertThat(v.id + " reverse ctx @" + e.getKey(), + rev.get(e.getKey()), equalTo(e.getValue())); + } + } } @Test From da3503b6bd00013b4b3d4018e581e894f0ccf713 Mon Sep 17 00:00:00 2001 From: Matt McCarthy Date: Wed, 29 Jul 2026 12:29:03 -0500 Subject: [PATCH 4/5] test: drop redundant per-vector order-only traversal tests --- .../server/ai/AgentGraphDefinitionTest.java | 78 ------------------- 1 file changed, 78 deletions(-) diff --git a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/AgentGraphDefinitionTest.java b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/AgentGraphDefinitionTest.java index e2874950..54561e1f 100644 --- a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/AgentGraphDefinitionTest.java +++ b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/AgentGraphDefinitionTest.java @@ -585,84 +585,6 @@ public void reverseTraverseDiamondGraph() { assertThat(visited, contains("sink", "a", "b", "root")); } - // ---- G1–G6 parity fixtures ------------------------------------------------- - - @Test - public void g1LinearExactOrder() { - AgentGraphDefinition graph = buildEnabled("a", - new String[][]{{"a", "b"}, {"b", "c"}}, "a", "b", "c"); - assertThat(visitOrder(graph, false, new HashMap()), contains("a", "b", "c")); - assertThat(visitOrder(graph, true, new HashMap()), contains("c", "b", "a")); - } - - @Test - public void g2SkewedDiamondExactOrder() { - // a→b, a→c, c→d, d→e, b→e - AgentGraphDefinition graph = buildEnabled("a", - new String[][]{{"a", "b"}, {"a", "c"}, {"c", "d"}, {"d", "e"}, {"b", "e"}}, - "a", "b", "c", "d", "e"); - assertThat(visitOrder(graph, false, new HashMap()), - contains("a", "b", "c", "d", "e")); - assertThat(visitOrder(graph, true, new HashMap()), - contains("e", "b", "d", "c", "a")); - } - - @Test - public void g2EdgeOrderIndependence() { - // Same as G2 but a's edges declared [c, b] - AgentGraphDefinition graph = buildEnabled("a", - new String[][]{{"a", "c"}, {"a", "b"}, {"c", "d"}, {"d", "e"}, {"b", "e"}}, - "a", "b", "c", "d", "e"); - List forward = visitOrder(graph, false, new HashMap()); - assertThat(forward, contains("a", "c", "b", "d", "e")); - assertThat(forward.indexOf("e") > forward.indexOf("d"), is(true)); - } - - @Test - public void g3SymmetricDiamondExactOrder() { - AgentGraphDefinition graph = buildEnabled("a", - new String[][]{{"a", "b"}, {"a", "c"}, {"b", "d"}, {"c", "d"}}, - "a", "b", "c", "d"); - assertThat(visitOrder(graph, false, new HashMap()), - contains("a", "b", "c", "d")); - assertThat(visitOrder(graph, true, new HashMap()), - contains("d", "b", "c", "a")); - } - - @Test - public void g4NestedParentExactOrder() { - AgentGraphDefinition graph = buildEnabled("a", - new String[][]{{"a", "n"}, {"n", "m"}, {"n", "t"}, {"m", "t"}}, - "a", "n", "m", "t"); - assertThat(visitOrder(graph, false, new HashMap()), - contains("a", "n", "m", "t")); - assertThat(visitOrder(graph, true, new HashMap()), - contains("t", "m", "n", "a")); - } - - @Test - public void g5MultiTerminalExactOrder() { - AgentGraphDefinition graph = buildEnabled("a", - new String[][]{{"a", "b"}, {"a", "c"}, {"b", "d"}}, - "a", "b", "c", "d"); - assertThat(visitOrder(graph, false, new HashMap()), - contains("a", "b", "c", "d")); - assertThat(visitOrder(graph, true, new HashMap()), - contains("c", "d", "b", "a")); - } - - @Test - public void g6CycleExactOrder() { - // a→b, b→c, c→b - AgentGraphDefinition graph = buildEnabled("a", - new String[][]{{"a", "b"}, {"b", "c"}, {"c", "b"}}, - "a", "b", "c"); - List forward = visitOrder(graph, false, new HashMap()); - List reverse = visitOrder(graph, true, new HashMap()); - assertThat(forward, contains("a", "b", "c")); - assertThat(reverse, contains("b", "c", "a")); - } - @Test public void vectorTraversalOrderAndContextParity() { for (Vector v : VECTORS) { From 3c24de64e52e86947d634a13e80363ccdbd4d169 Mon Sep 17 00:00:00 2001 From: Matt McCarthy Date: Mon, 10 Aug 2026 13:22:29 -0500 Subject: [PATCH 5/5] fix: exclude root edges from reverseTraverse outdeg --- .../sdk/server/ai/AgentGraphDefinition.java | 3 ++- .../server/ai/AgentGraphDefinitionTest.java | 18 +++++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AgentGraphDefinition.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AgentGraphDefinition.java index 4fc83a3f..b21fb40b 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AgentGraphDefinition.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AgentGraphDefinition.java @@ -267,7 +267,8 @@ public void reverseTraverse(BiFunction, Obje AgentGraphNode node = getNode(k); if (node != null) { for (GraphEdge e : node.getEdges()) { - if (reachable.contains(e.getKey())) { + // The root is visited last, outside this loop, so no node waits on it. + if (!e.getKey().equals(rootKey) && reachable.contains(e.getKey())) { d++; } } diff --git a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/AgentGraphDefinitionTest.java b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/AgentGraphDefinitionTest.java index 54561e1f..4671fe06 100644 --- a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/AgentGraphDefinitionTest.java +++ b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/AgentGraphDefinitionTest.java @@ -443,7 +443,23 @@ private static Map> ctx(Object... nodeAndDeps) { lists("a", "b", "c"), lists("b", "c", "a"), ctx("a", set(), "b", set("a"), "c", set("a", "b")), - ctx("a", set("b", "c"), "b", set(), "c", set("b"))) + ctx("a", set("b", "c"), "b", set(), "c", set("b"))), + new Vector( + "G7", "a", + new String[][]{{"a", "b"}, {"b", "c"}, {"c", "a"}}, + new String[]{"a", "b", "c"}, + lists("a", "b", "c"), + lists("c", "b", "a"), + ctx("a", set(), "b", set("a"), "c", set("a", "b")), + ctx("a", set("b", "c"), "b", set("c"), "c", set())), + new Vector( + "G8", "a", + new String[][]{{"a", "b"}, {"b", "a"}}, + new String[]{"a", "b"}, + lists("a", "b"), + lists("b", "a"), + ctx("a", set(), "b", set("a")), + ctx("a", set("b"), "b", set())) ); // ---- traverse -------------------------------------------------------------