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..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
@@ -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,76 @@ 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);
+ }
- while (!queue.isEmpty()) {
- AgentGraphNode node = queue.poll();
- Object result = fn.apply(node, ctx);
- ctx.put(node.getKey(), result);
+ // 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)) {
+ continue;
+ }
+ anc.add(pk);
+ Set parentAnc = ancestors.get(pk);
+ if (parentAnc != null) {
+ anc.addAll(parentAnc);
+ }
+ }
+ ancestors.put(next, anc);
+ visited.add(next);
+
+ 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 +255,174 @@ 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()) {
+ // The root is visited last, outside this loop, so no node waits on it.
+ if (!e.getKey().equals(rootKey) && 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);
+ }
- // 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);
+ // 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) {
+ 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);
+ visited.add(next);
+ 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..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
@@ -1,9 +1,10 @@
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.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;
@@ -298,35 +300,191 @@ 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;
+ }
+
+ /** 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"))),
+ 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 -------------------------------------------------------------
@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 +499,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 +533,38 @@ 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);
+ AgentGraphDefinition graph = buildEnabled("a",
+ new String[][]{{"a", "b"}, {"b", "a"}}, "a", "b");
- AgentGraphFlagValue fv = flagValue("a", new String[][]{{"a", "b"}, {"b", "a"}});
- AgentGraphDefinition graph = new AgentGraphDefinition(fv, nodes, true, null);
+ 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"));
+ }
- 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));
+ @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
@@ -432,17 +581,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 +596,59 @@ 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"));
+ }
+
+ @Test
+ public void vectorTraversalOrderAndContextParity() {
+ for (Vector v : VECTORS) {
+ AgentGraphDefinition graph = buildEnabled(v.root, v.edges, v.nodeKeys);
+ Map empty = new HashMap<>();
+
+ assertThat(v.id + " forward order",
+ visitOrder(graph, false, empty), equalTo(v.fwdOrder));
+ assertThat(v.id + " reverse order",
+ visitOrder(graph, true, empty), equalTo(v.revOrder));
+
+ 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
+ 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));
+ }
}
}