From c277915358b28f3ca5be1f9a3e84b1cd9e6549a6 Mon Sep 17 00:00:00 2001 From: JPPhoto Date: Sun, 25 Oct 2026 08:25:19 -0500 Subject: [PATCH 1/7] fix(graph): align If execution with iterator frames --- invokeai/app/services/shared/README.md | 12 +- .../services/shared/graph_if_activation.py | 5 +- .../app/services/shared/graph_materializer.py | 42 +++-- .../test_execution_engine_differential.py | 146 ++++++++++++++++-- 4 files changed, 179 insertions(+), 26 deletions(-) diff --git a/invokeai/app/services/shared/README.md b/invokeai/app/services/shared/README.md index 49d19429b79..49e0f095a37 100644 --- a/invokeai/app/services/shared/README.md +++ b/invokeai/app/services/shared/README.md @@ -849,17 +849,17 @@ Run `C` -> `D:0` -> enqueue `D`. Run `D` -> done. through the loop linkage) is gated with that branch: for a frame that rejects the branch, neither the `For` nor its body runs. A gated `For` brings in a `For` nested in its body together with that body. Two `For`s stay ungated. A `For` nested in the body of an ungated `For` runs with that loop. A `For` whose final output feeds the `If` directly - is attached only after it finalizes, and the `If` is not prepared before that, so gating it on the `If` would - deadlock. Known limitation: with a per-item condition, such a `For` and its body run even for items that reject the - branch, and an error they raise is recorded. + attaches after it finalizes; with a per-item condition, rejected frames do not run the `For` or its body, and selected + frames wait for its final output before the `If` input is attached. - An `If` has no execution in a frame where the innermost iterator in its ancestry has no items. When an `Iterate` in its iterator ancestry, whether it feeds `If.condition` or a branch, has no items at all, no `If` execution is created, the branch sources are retired empty, the downstream `Collect` produces an empty collection, and the state completes without an error. The same holds for a literal condition whose selected branch runs under an empty `Iterate` reached only through a `For` final output. Retirement may add the branch source IDs to `executed_history`; neither branch - invocation runs and no branch result is created. Known limitation: with a wired condition, an `If` whose branch - reaches an empty `Iterate` only through a `For` final output stalls, whether the condition is root-frame or per item - of that `Iterate`. + invocation runs and no branch result is created. With a wired root-frame condition and one parent `Iterate` carried + through a selected `For` final output, the `If` broadcasts over the parent frames; an empty parent `Iterate` gives it + no executions and the downstream `Collect` remains empty. Multiple unrelated open frames that cannot be paired still + fail validation before execution. - The SQLite queue/processor path has evidence for cancellation before and after `If` resolution and retry from each boundary for both condition polarities. A canceled attempt keeps its activation ledger and cannot resume; retry starts a fresh state and must emit a fresh matching activation token before selected-only completion. diff --git a/invokeai/app/services/shared/graph_if_activation.py b/invokeai/app/services/shared/graph_if_activation.py index 86db0eb3c19..34d6c366063 100644 --- a/invokeai/app/services/shared/graph_if_activation.py +++ b/invokeai/app/services/shared/graph_if_activation.py @@ -45,8 +45,8 @@ def _branch_sources(self, if_node_id: str, branch_field: str, source_graph: "nx. # A For's loop body feeds its ForReturn, which reaches the For only through the loop linkage. The body belongs # to the branch whenever its For does, and leaves it with the For. Fors are visited outermost first, so a gated # For brings in a For nested in its body together with that body. Two Fors stay ungated. One whose final - # output enters the If directly is attached only after it finalizes, and the If is not prepared before that, - # so the For cannot wait for the If's verdict. One nested in an ungated For's body is expanded by that loop, + # output enters the If directly is attached after it finalizes. With a wired condition the For and its body + # can still be gated once that condition resolves. One nested in an ungated For's body is expanded by that loop, # which needs it to run. (A For enclosing the If is a frame iterator of the If and was removed above.) for_by_return_id: dict[str, str] = {} if any(isinstance(self._state.graph.nodes.get(node_id), ForInvocation) for node_id in branch_sources): @@ -61,6 +61,7 @@ def _branch_sources(self, if_node_id: str, branch_field: str, source_graph: "nx. if ( for_id not in branch_sources or for_id in direct_sources + and not self._state.graph._get_input_edges(if_node_id, "condition") or any( for_id in body_ids_by_for[enclosing_id] and enclosing_id not in for_by_return_id.values() for enclosing_id in body_ids_by_for diff --git a/invokeai/app/services/shared/graph_materializer.py b/invokeai/app/services/shared/graph_materializer.py index 816fd4ddeef..8b12a681432 100644 --- a/invokeai/app/services/shared/graph_materializer.py +++ b/invokeai/app/services/shared/graph_materializer.py @@ -275,12 +275,18 @@ def _ensure_activation_candidate_index(self, graph: "nx.DiGraph") -> None: if not isinstance(self._state.graph.get_node(if_node_id), IfInvocation): continue condition_edges = self._state.graph._get_input_edges(if_node_id, "condition") - if len(condition_edges) != 1: - continue - condition_source_id = condition_edges[0].source.node_id - if isinstance(self._state.graph.get_node(condition_source_id), IterateInvocation): - self._empty_condition_if_sources.setdefault(condition_source_id, ()) - self._empty_condition_if_sources[condition_source_id] += (if_node_id,) + iterator_graph = self.iterator_graph(graph) + if len(condition_edges) == 1 and isinstance( + self._state.graph.get_node(condition_edges[0].source.node_id), IterateInvocation + ): + empty_frame_sources = (condition_edges[0].source.node_id,) + elif condition_edges and not self._get_ordered_iterator_axes_for_edges(condition_edges, iterator_graph): + empty_frame_sources = self._get_iterator_ancestry(iterator_graph).get_frame_iterators(if_node_id) + else: + empty_frame_sources = () + for source_id in empty_frame_sources: + self._empty_condition_if_sources.setdefault(source_id, ()) + self._empty_condition_if_sources[source_id] += (if_node_id,) self._activation_retirement_rebuild_required = True self._rebuild_activation_retirement_candidates() @@ -1748,7 +1754,7 @@ def _get_if_condition_iteration_mappings( if condition_iterator_ids: iterator_nodes = condition_axes else: - iterator_nodes = self.get_node_iterators(node_id, iterator_graph) + iterator_nodes = list(self._get_iterator_ancestry(iterator_graph).get_frame_iterators(node_id)) else: iterator_nodes = self.get_node_iterators(node_id, iterator_graph) if condition_edges and not iterator_nodes: @@ -1768,7 +1774,14 @@ def _get_if_condition_iteration_mappings( # This specialization derives one branch axis against a root-frame condition. Mixed or nested condition # axes use the general parent-mapping path. iterator_ancestry = self._get_iterator_ancestry(iterator_graph) - iterator_nodes = self._get_if_frame_iterator_nodes(node_id, iterator_graph) + if condition_edges: + if all( + outer_id in iterator_ancestry.get_ordered_iterator_ancestors(inner_id) + for outer_id, inner_id in itertools.pairwise(iterator_nodes) + ): + iterator_nodes = iterator_nodes[-1:] + else: + iterator_nodes = self._get_if_frame_iterator_nodes(node_id, iterator_graph) if len(iterator_nodes) != 1: return iter([[]]) for branch_field in ("true_input", "false_input"): @@ -1963,6 +1976,13 @@ def _attach_pending_if_inputs(self, *, enqueue: bool = True) -> None: source_if_id = self._state._prepared_registry().get_source_node_id(exec_node_id) target_path = self._state._get_iteration_path(exec_node_id) selected_edges = self._state.graph._get_input_edges(source_if_id, selected_field) + if any( + isinstance(source_node := self._state.graph.get_node(edge.source.node_id), ForInvocation) + and get_output_field_scope(source_node, edge.source.field) == OutputScope.Final + and not self._state._all_for_contexts_finalized(source_node.id) + for edge in selected_edges + ): + continue attached_edges: list[Edge] = [] selected_exec_ids: list[str] = [] for source_edge in selected_edges: @@ -2419,7 +2439,11 @@ def prepare(self, base_g: Optional["nx.DiGraph"] = None) -> Optional[str]: isinstance(self._state.graph.get_node(node_id), ForReturnInvocation) and self._is_deferred_nested_for_return(node_id) ) - and not self._has_unmaterializable_for_final_input(node_id) + and ( + isinstance(self._state.graph.get_node(node_id), IfInvocation) + and bool(self._state.graph._get_input_edges(node_id, "condition")) + or not self._has_unmaterializable_for_final_input(node_id) + ) and node_id not in deferred_root_collect_if_descendants and ( all( diff --git a/tests/app/services/shared/test_execution_engine_differential.py b/tests/app/services/shared/test_execution_engine_differential.py index 38a42f86ed2..858a9f2787a 100644 --- a/tests/app/services/shared/test_execution_engine_differential.py +++ b/tests/app/services/shared/test_execution_engine_differential.py @@ -3537,12 +3537,42 @@ def test_root_condition_if_with_for_output_branch_under_iterate_completes( trace, state = _run_to_completion(_graph_from(nodes, edges)) # Selecting the root value keeps main's single root-frame If; selected-branch cardinality is decided separately. - expected = [["b:a", "b:b"], ["b:c"]] if condition else ["fb"] + expected = [["b:a", "b:b"], ["b:c"]] if condition else (["fb", "fb"] if computed_condition else ["fb"]) assert _source_values(state, "collect") == {(): expected} if not condition: assert not {"for", "body", "ret", "mid"} & set(trace) +def test_literal_root_condition_ignores_unselected_for_output_branch_under_iterate() -> None: + graph = _graph_from( + [ + CollectionConcatInvocation(id="src", first=[["a", "b"], ["c"]]), + IterateInvocation(id="iterate"), + ForInvocation(id="for"), + MarkedAnyTypeTestInvocation(id="body", marker="b:"), + ForReturnInvocation(id="ret"), + AnyTypeTestInvocation(id="mid"), + AnyTypeTestInvocation(id="fallback", value="fb"), + IfInvocation(id="if", condition=False), + CollectInvocation(id="collect"), + ], + [ + ("src", "collection", "iterate", "collection"), + ("iterate", "item", "for", "collection"), + ("for", "item", "body", "value"), + ("body", "value", "ret", "output"), + create_loop_linkage("for", "ret"), + ("for", "output_collection", "mid", "value"), + ("mid", "value", "if", "true_input"), + ("fallback", "value", "if", "false_input"), + ("if", "value", "collect", "item"), + ], + ) + trace, state = _run_to_completion(graph) + assert _source_values(state, "collect") == {(): ["fb"]} + assert not {"for", "body", "ret", "mid"} & set(trace) + + @pytest.mark.parametrize("condition", [True, False]) def test_literal_if_with_empty_nested_iterate_on_a_branch_has_no_frames(condition: bool) -> None: """An open Iterate with no items gives the If no frame, whichever branch the literal condition selects.""" @@ -3575,6 +3605,100 @@ def test_literal_if_with_empty_nested_iterate_on_a_branch_has_no_frames(conditio assert "if" not in trace +@pytest.mark.parametrize("force_compatibility_scheduler", [False, True], ids=["default", "forced-compatibility"]) +def test_wired_root_condition_with_empty_for_parent_frame_completes( + force_compatibility_scheduler: bool, +) -> None: + graph = _graph_from( + [ + CollectionConcatInvocation(id="src", first=[]), + IterateInvocation(id="iterate"), + ForInvocation(id="for"), + ForReturnInvocation(id="ret"), + FixedCollectionTestInvocation(id="condition", value=True), + AnyTypeTestInvocation(id="fallback", value="fb"), + IfInvocation(id="if"), + CollectInvocation(id="collect"), + ], + [ + ("src", "collection", "iterate", "collection"), + ("iterate", "item", "for", "collection"), + ("for", "item", "ret", "output"), + create_loop_linkage("for", "ret"), + ("for", "output_collection", "if", "true_input"), + ("condition", "condition", "if", "condition"), + ("fallback", "value", "if", "false_input"), + ("if", "value", "collect", "item"), + ], + ) + state = GraphExecutionState(graph=graph) + if force_compatibility_scheduler: + state._execution_scheduler = _ExecutionScheduler(state) + + trace: list[str] = [] + while (node := state.next()) is not None: + source_id = state.prepared_source_mapping[node.id] + trace.append(source_id) + state.complete(node.id, node.invoke(Mock())) + + assert not state.has_error(), state.errors + assert state.is_complete(), f"trace={trace}; executed={state.executed}; prepared={state.source_prepared_mapping}" + assert _source_values(state, "collect") == {(): []} + assert not {"if", "for", "ret", "fallback"} & set(trace) + + +@pytest.mark.parametrize( + "force_compatibility_scheduler", [False, True], ids=["default-route", "forced-compatibility-route"] +) +def test_per_item_if_skips_direct_for_output_in_rejected_frames( + force_compatibility_scheduler: bool, +) -> None: + graph = _graph_from( + [ + CollectionConcatInvocation(id="src", first=["", "x"]), + IterateInvocation(id="iterate"), + FixedCollectionTestInvocation(id="condition"), + ForInvocation(id="for"), + AnyTypeTestInvocation(id="body"), + ForReturnInvocation(id="ret"), + AnyTypeTestInvocation(id="fallback", value="fb"), + IfInvocation(id="if"), + CollectInvocation(id="collect"), + ], + [ + ("src", "collection", "iterate", "collection"), + ("iterate", "item", "condition", "value"), + ("condition", "condition", "if", "condition"), + ("condition", "collection", "for", "collection"), + ("for", "item", "body", "value"), + ("body", "value", "ret", "output"), + create_loop_linkage("for", "ret"), + ("for", "output_collection", "if", "true_input"), + ("fallback", "value", "if", "false_input"), + ("if", "value", "collect", "item"), + ], + ) + state = GraphExecutionState(graph=graph) + if force_compatibility_scheduler: + state._execution_scheduler = _ExecutionScheduler(state) + trace: list[tuple[str, tuple[int, ...]]] = [] + while (node := state.next()) is not None: + source_id = state.prepared_source_mapping[node.id] + iteration_path = state._get_iteration_path(node.id) + trace.append((source_id, iteration_path)) + state.complete(node.id, node.invoke(Mock())) + + assert not state.has_error(), state.errors + assert state.is_complete(), f"execution stalled after {trace}" + assert _source_values(state, "if") == {(0,): "fb", (1,): [0, 1]} + assert _source_values(state, "collect") == {(): ["fb", [0, 1]]} + assert [path for source_id, path in trace if source_id == "if"] == [(0,), (1,)] + assert [path for source_id, path in trace if source_id == "for"] == [(1, 0), (1, 1)] + assert [path for source_id, path in trace if source_id == "body"] == [(1, 0), (1, 1)] + assert [path for source_id, path in trace if source_id == "ret"] == [(1, 0), (1, 1)] + assert not any(source_id in {"for", "body", "ret"} and path[:1] == (0,) for source_id, path in trace) + + def _unrelated_open_branches_graph(condition: bool, computed_condition: bool) -> Graph: nodes = [ CollectionConcatInvocation(id="true_src", first=[1, 2]), @@ -10998,16 +11122,17 @@ def test_per_item_if_preserves_unrelated_root_collect_frames( @pytest.mark.parametrize( ("conditions", "expected"), [ - ([False, True, True], ["fb", [5, 6], [5, 6]]), - ([True, True], [[5, 6], [5, 6]]), + pytest.param([False, True, True], ["fb", [5, 6], [5, 6]], id="mixed-conditions"), + pytest.param([True, True], [[5, 6], [5, 6]], id="all-true"), + pytest.param([False, False, False], ["fb", "fb", "fb"], id="all-false"), ], - ids=["mixed-conditions", "all-true"], ) def test_per_item_if_keeps_closed_for_output_collection( force_compatibility_scheduler: bool, conditions: list[bool], expected: list[Any], ) -> None: + """Root For item paths are distinct from condition frames; no selected frame means no For work.""" graph = Graph() graph.add_node(CollectionConcatInvocation(id="conditions", first=conditions)) graph.add_node(IterateInvocation(id="condition_iterate")) @@ -11037,11 +11162,14 @@ def test_per_item_if_keeps_closed_for_output_collection( assert state.is_complete(), f"graph stalled after {trace}" output_exec_id = next(iter(state.source_prepared_mapping["output"])) assert state.results[output_exec_id].collection == expected - final_for_exec_id = max( - state.source_prepared_mapping["for"], - key=lambda exec_node_id: state.execution_graph.get_node(exec_node_id).index, - ) - assert state.results[final_for_exec_id].output_collection == [5, 6] + if any(conditions): + final_for_exec_id = max( + state.source_prepared_mapping["for"], + key=lambda exec_node_id: state.execution_graph.get_node(exec_node_id).index, + ) + assert state.results[final_for_exec_id].output_collection == [5, 6] + else: + assert not {"for", "body", "for_return"} & set(trace) @pytest.mark.parametrize("force_compatibility_scheduler", [False, True], ids=["generic", "compatibility"]) From 41c651c2034fbcbedfd6514712c7b89a25c07626 Mon Sep 17 00:00:00 2001 From: JPPhoto Date: Sun, 25 Oct 2026 10:07:33 -0500 Subject: [PATCH 2/7] fix(graph): resolve matching Collect axes for If --- invokeai/app/services/shared/README.md | 6 +- .../app/services/shared/graph_if_runtime.py | 156 ++++++++++----- .../app/services/shared/graph_materializer.py | 81 +++++++- .../test_execution_engine_differential.py | 177 ++++++++++++++++++ 4 files changed, 366 insertions(+), 54 deletions(-) diff --git a/invokeai/app/services/shared/README.md b/invokeai/app/services/shared/README.md index 49e0f095a37..5ef88e87258 100644 --- a/invokeai/app/services/shared/README.md +++ b/invokeai/app/services/shared/README.md @@ -837,7 +837,11 @@ Run `C` -> `D:0` -> enqueue `D`. Run `D` -> done. (a node between the `Collect` and the `Iterate`, or extra collected items) can change the length, and selecting the upstream axis over its reopening pairs it with per-group frames; both would drop or invent frames. - A branch whose open axes, or the axes a branch `Collect` keeps, are deeper than a per-item condition's, unless - both branches carry the same deeper axes. + both branches carry the same deeper axes. Each `Collect.item` closes its innermost axis and keeps the outer axes; + this identity is followed through chained `Collect`s and ordinary passthrough nodes. A shared deeper axis lets the + `If` run once per available innermost frame. When both branches are Collect-backed, they must share a Collect in + that chain; matching axis names or equal collection lengths alone are insufficient. An asymmetric Collect chain + is rejected before invocation. A downstream `Collect` retains empty outer groups. - A root-frame condition choosing between branches with different deeper axes. When both branches share nested deeper axes, a root-frame condition runs the `If` once per frame of the innermost one. Selected-branch frame cardinality is not supported. diff --git a/invokeai/app/services/shared/graph_if_runtime.py b/invokeai/app/services/shared/graph_if_runtime.py index c9a75e9438b..27a5fecf8b4 100644 --- a/invokeai/app/services/shared/graph_if_runtime.py +++ b/invokeai/app/services/shared/graph_if_runtime.py @@ -15,6 +15,7 @@ ForInvocation, IterateInvocation, get_output_field_scope, + nx, ) if TYPE_CHECKING: @@ -28,13 +29,13 @@ class UnsupportedIfIterationAxesError(ValueError): def validate_if_iteration_axes(state: "GraphExecutionState") -> None: """Reject unrelated condition and branch axes using graph topology alone.""" iterator_ancestry = _IteratorAncestry(state) - collect_axes_cache: dict[str, Optional[tuple[str, ...]]] = {} + frame_axes_cache: dict[str, Optional[tuple[str, ...]]] = {} for node_id, node in state.graph.nodes.items(): if not isinstance(node, IfInvocation): continue condition_edges = state.graph._get_input_edges(node_id, "condition") if not condition_edges: - _validate_frame_axes(state, iterator_ancestry, node_id, condition_edges, collect_axes_cache) + _validate_frame_axes(state, iterator_ancestry, node_id, condition_edges, frame_axes_cache) continue condition_iterators = iterator_ancestry.get_iterator_ancestors_for_edge(condition_edges[0]) branch_edges = { @@ -72,7 +73,7 @@ def validate_if_iteration_axes(state: "GraphExecutionState") -> None: f"unrelated open iterator axes: condition {sorted(condition_iterators)}, " f"branch {sorted(branch_iterators)}" ) - _validate_frame_axes(state, iterator_ancestry, node_id, condition_edges, collect_axes_cache) + _validate_frame_axes(state, iterator_ancestry, node_id, condition_edges, frame_axes_cache) def _validate_frame_axes( @@ -80,7 +81,7 @@ def _validate_frame_axes( iterator_ancestry: _IteratorAncestry, node_id: str, condition_edges: list[Edge], - collect_axes_cache: dict[str, Optional[tuple[str, ...]]], + frame_axes_cache: dict[str, Optional[tuple[str, ...]]], ) -> None: """Reject frame sets an If cannot run in. @@ -97,7 +98,7 @@ def _validate_frame_axes( A root-frame condition cannot select between branches with different deeper axes. """ if condition_edges: - _validate_collect_branch_axes(state, iterator_ancestry, node_id, condition_edges[0]) + _validate_collect_branch_axes(state, iterator_ancestry, node_id, condition_edges[0], frame_axes_cache) frame_iterators = iterator_ancestry.get_frame_iterators(node_id) if condition_edges: deciding_edges = condition_edges @@ -120,9 +121,7 @@ def _validate_frame_axes( ) if unselected_id not in iterator_ancestry.get_ordered_iterator_ancestors(node_id): continue - if unselected_id in _get_reopened_source_axes( - state, iterator_ancestry, selected_id, collect_axes_cache - ): + if unselected_id in _get_reopened_source_axes(state, iterator_ancestry, selected_id, frame_axes_cache): continue raise UnsupportedIfIterationAxesError( f"If node '{node_id}' cannot run in the frames of unrelated open iterator axes: " @@ -150,7 +149,7 @@ def _get_reopened_source_axes( state: "GraphExecutionState", iterator_ancestry: _IteratorAncestry, axis_id: str, - collect_axes_cache: dict[str, Optional[tuple[str, ...]]], + frame_axes_cache: dict[str, Optional[tuple[str, ...]]], ) -> set[str]: """Return the axes whose frames an axis, or an iterator enclosing it, reopens one-to-one. @@ -181,7 +180,7 @@ def _get_reopened_source_axes( collect_inputs = state.graph._get_input_edges(collect_id) if len(collect_inputs) != 1 or collect_inputs[0].destination.field != ITEM_FIELD: continue - item_axes = _get_edge_frame_axes(state, iterator_ancestry, collect_inputs[0], collect_axes_cache) + item_axes = _get_edge_frame_axes(state, iterator_ancestry, collect_inputs[0], frame_axes_cache) if item_axes is None or not _is_nested_chain(iterator_ancestry, item_axes): continue sources.update(item_axes) @@ -190,40 +189,43 @@ def _get_reopened_source_axes( def _validate_collect_branch_axes( - state: "GraphExecutionState", iterator_ancestry: _IteratorAncestry, node_id: str, condition_edge: Edge + state: "GraphExecutionState", + iterator_ancestry: _IteratorAncestry, + node_id: str, + condition_edge: Edge, + frame_axes_cache: dict[str, Optional[tuple[str, ...]]], ) -> None: """Reject a branch Collect that keeps axes open below a per-item condition unless the other branch shares them.""" condition_axes = set(iterator_ancestry.get_frame_axes_for_edge(condition_edge)) if not condition_axes: return branch_axes: dict[str, set[str]] = {} + branch_collect_sources: dict[str, set[str]] = {} + source_graph = state._get_source_graph_flat() for branch_field in ("true_input", "false_input"): axes: set[str] = set() + collect_sources: set[str] = set() for edge in state.graph._get_input_edges(node_id, branch_field): - if isinstance(state.graph.get_node(edge.source.node_id), CollectInvocation): - axes.update(_get_collect_kept_axes(state, iterator_ancestry, edge.source.node_id)) - else: - axes.update(iterator_ancestry.get_frame_axes_for_edge(edge)) + axes.update(_get_edge_frame_axes(state, iterator_ancestry, edge, frame_axes_cache) or ()) + ancestors = nx.ancestors(source_graph, edge.source.node_id) | {edge.source.node_id} + collect_sources.update( + source_id for source_id in ancestors if isinstance(state.graph.get_node(source_id), CollectInvocation) + ) branch_axes[branch_field] = axes + branch_collect_sources[branch_field] = collect_sources for branch_field, other_field in (("true_input", "false_input"), ("false_input", "true_input")): axes = branch_axes[branch_field] - if axes > condition_axes and axes != branch_axes[other_field]: + both_collect_backed = bool(branch_collect_sources[branch_field]) and bool(branch_collect_sources[other_field]) + shared_collect_chain = bool(branch_collect_sources[branch_field] & branch_collect_sources[other_field]) + if axes > condition_axes and ( + axes != branch_axes[other_field] or (both_collect_backed and not shared_collect_chain) + ): raise UnsupportedIfIterationAxesError( f"If node '{node_id}' cannot select branch input '{branch_field}' from deeper open iterator axes " f"kept by a Collect: condition {sorted(condition_axes)}, branch {sorted(axes)}" ) -def _get_collect_kept_axes( - state: "GraphExecutionState", iterator_ancestry: _IteratorAncestry, collect_id: str -) -> set[str]: - """Return the axes a Collect's output stays in: its item's axes except the innermost one, which it closes.""" - kept: set[str] = set() - for edge in state.graph._get_input_edges(collect_id): - kept.update(_without_closed_axis(state, edge, iterator_ancestry.get_frame_axes_for_edge(edge))) - return kept - - def _without_closed_axis(state: "GraphExecutionState", edge: Edge, axes: tuple[str, ...]) -> tuple[str, ...]: """Return the axes a Collect input keeps: an item closes its innermost axis unless it is a For's final output.""" source = state.graph.get_node(edge.source.node_id) @@ -247,21 +249,40 @@ def _get_edge_frame_axes( state: "GraphExecutionState", iterator_ancestry: _IteratorAncestry, edge: Edge, - collect_axes_cache: dict[str, Optional[tuple[str, ...]]], + frame_axes_cache: dict[str, Optional[tuple[str, ...]]], ) -> Optional[tuple[str, ...]]: - """Return the frames open on an edge, outer to inner, following a Collect source into the axes it keeps. + """Resolve open axes through Collects and ordinary nodes that pass their collection output through. - Iterator ancestry stops at a Collect, so a Collect's output would otherwise look like it runs in no frames. A - Collect whose kept axes are not one nested chain, or that collects such a Collect, has no frame order a reopen could - pair with, so it yields None. Results are cached per Collect for one validation run and resolved with an explicit - stack, so a lattice or a long chain of Collects costs one pass per node. + Iterator ancestry stops at a Collect, so following its output requires closing its item axis and retaining the + outer axes. A result that is not one nested chain has no frame order to pair with and yields None. Results are + cached for one validation run; Collect chains are resolved with an explicit stack. """ - if not isinstance(state.graph.get_node(edge.source.node_id), CollectInvocation): - return iterator_ancestry.get_frame_axes_for_edge(edge) + source_node = state.graph.get_node(edge.source.node_id) + if not isinstance(source_node, CollectInvocation): + direct_axes = iterator_ancestry.get_frame_axes_for_edge(edge) + if isinstance(source_node, (IfInvocation, ForInvocation, IterateInvocation)): + return direct_axes + if edge.source.node_id in frame_axes_cache: + return frame_axes_cache[edge.source.node_id] + axes = set(direct_axes) + for input_edge in state.graph._get_input_edges(edge.source.node_id): + input_axes = _get_edge_frame_axes(state, iterator_ancestry, input_edge, frame_axes_cache) + if input_axes is None: + frame_axes_cache[edge.source.node_id] = None + return None + axes.update(input_axes) + ordered_axes = tuple( + sorted(axes, key=lambda axis_id: (len(iterator_ancestry.get_frame_iterators(axis_id)), axis_id)) + ) + if not _is_nested_chain(iterator_ancestry, ordered_axes): + frame_axes_cache[edge.source.node_id] = None + return None + frame_axes_cache[edge.source.node_id] = ordered_axes + return ordered_axes pending = [edge.source.node_id] while pending: collect_id = pending[-1] - if collect_id in collect_axes_cache: + if collect_id in frame_axes_cache: pending.pop() continue input_edges = state.graph._get_input_edges(collect_id) @@ -269,7 +290,7 @@ def _get_edge_frame_axes( input_edge.source.node_id for input_edge in input_edges if isinstance(state.graph.get_node(input_edge.source.node_id), CollectInvocation) - and input_edge.source.node_id not in collect_axes_cache + and input_edge.source.node_id not in frame_axes_cache ] if unresolved: pending.extend(unresolved) @@ -277,11 +298,7 @@ def _get_edge_frame_axes( pending.pop() kept: Optional[set[str]] = set() for input_edge in input_edges: - source_id = input_edge.source.node_id - if isinstance(state.graph.get_node(source_id), CollectInvocation): - input_axes = collect_axes_cache[source_id] - else: - input_axes = iterator_ancestry.get_frame_axes_for_edge(input_edge) + input_axes = _get_edge_frame_axes(state, iterator_ancestry, input_edge, frame_axes_cache) if input_axes is None: kept = None break @@ -293,8 +310,8 @@ def _get_edge_frame_axes( ) if not _is_nested_chain(iterator_ancestry, ordered): ordered = None - collect_axes_cache[collect_id] = ordered - return collect_axes_cache[edge.source.node_id] + frame_axes_cache[collect_id] = ordered + return frame_axes_cache[edge.source.node_id] def _get_effective_iteration_paths_for_edge( @@ -404,6 +421,26 @@ def _get_if_iteration_paths_with_ancestry( assert isinstance(node, IfInvocation) next_visited_source_ids = {*visited_source_ids, node_id} condition_edges = state.graph._get_input_edges(node_id, "condition") + condition_axes: set[str] = set() + for edge in condition_edges: + try: + condition_axes.update(iterator_ancestry.get_frame_axes_for_edge(edge)) + except nx.NetworkXError: + # A filtered materialization view can omit unresolved condition sources. + continue + branch_axes = { + branch: { + axis_id + for edge in state.graph._get_input_edges(node_id, branch) + for axis_id in (_get_edge_frame_axes(state, iterator_ancestry, edge, {}) or ()) + } + for branch in ("true_input", "false_input") + } + shared_deeper_branch_axes = ( + bool(branch_axes["true_input"]) + and branch_axes["true_input"] == branch_axes["false_input"] + and branch_axes["true_input"] > condition_axes + ) condition_selections: list[tuple[Optional[tuple[int, ...]], str]] = [] if condition_edges: @@ -457,8 +494,37 @@ def _get_if_iteration_paths_with_ancestry( for edge in selected_edges ) ): - # The selected branch is not materialized yet; the If still runs in the condition's frame. - branch_paths = {condition_path} + if shared_deeper_branch_axes: + other_field = "false_input" if selected_field == "true_input" else "true_input" + other_edges = state.graph._get_input_edges(node_id, other_field) + branch_paths = ( + set().union( + *( + _get_effective_iteration_paths_for_edge( + state, + edge, + next_visited_source_ids, + get_prepared_nodes_for_source, + get_prepared_edge_iteration_path, + iterator_ancestry, + effective_paths_by_edge, + ) + for edge in other_edges + ) + ) + if other_edges + else set() + ) + else: + # The selected branch is not materialized yet; the If still runs in the condition's frame. + branch_paths = {condition_path} + + if shared_deeper_branch_axes and condition_path is not None: + branch_paths = { + branch_path + for branch_path in branch_paths + if len(branch_path) > len(condition_path) and branch_path[: len(condition_path)] == condition_path + } for branch_path in branch_paths: if condition_path is None: diff --git a/invokeai/app/services/shared/graph_materializer.py b/invokeai/app/services/shared/graph_materializer.py index 8b12a681432..ace71c25e2f 100644 --- a/invokeai/app/services/shared/graph_materializer.py +++ b/invokeai/app/services/shared/graph_materializer.py @@ -276,14 +276,18 @@ def _ensure_activation_candidate_index(self, graph: "nx.DiGraph") -> None: continue condition_edges = self._state.graph._get_input_edges(if_node_id, "condition") iterator_graph = self.iterator_graph(graph) - if len(condition_edges) == 1 and isinstance( - self._state.graph.get_node(condition_edges[0].source.node_id), IterateInvocation - ): - empty_frame_sources = (condition_edges[0].source.node_id,) - elif condition_edges and not self._get_ordered_iterator_axes_for_edges(condition_edges, iterator_graph): - empty_frame_sources = self._get_iterator_ancestry(iterator_graph).get_frame_iterators(if_node_id) - else: - empty_frame_sources = () + ancestry = self._get_iterator_ancestry(iterator_graph) + empty_frame_sources = tuple( + dict.fromkeys( + [ + axis_id + for edge in condition_edges + if iterator_graph.has_node(edge.source.node_id) + for axis_id in ancestry.get_iterator_axes_for_edge(edge) + ] + + list(ancestry.get_frame_iterators(if_node_id)) + ) + ) for source_id in empty_frame_sources: self._empty_condition_if_sources.setdefault(source_id, ()) self._empty_condition_if_sources[source_id] += (if_node_id,) @@ -1252,6 +1256,28 @@ def _get_collect_iteration_group_key(self, edge: Edge, sibling_depth: Optional[i def _get_collect_source_iterator_ids( self, source_node_id: str, iterator_graph: Optional["nx.DiGraph"] = None ) -> list[str]: + if isinstance(self._state.graph.get_node(source_node_id), IfInvocation): + iterator_ancestry = self._get_iterator_ancestry(iterator_graph or self.iterator_graph()) + branch_axes = [ + tuple( + dict.fromkeys( + axis_id + for edge in self._state.graph._get_input_edges(source_node_id, branch) + for axis_id in ( + graph_if_runtime._get_edge_frame_axes(self._state, iterator_ancestry, edge, {}) or () + ) + ) + ) + for branch in ("true_input", "false_input") + ] + condition_axes = { + axis_id + for edge in self._state.graph._get_input_edges(source_node_id, "condition") + for axis_id in iterator_ancestry.get_frame_axes_for_edge(edge) + } + if branch_axes[0] and branch_axes[0] == branch_axes[1] and set(branch_axes[0]) > condition_axes: + return list(branch_axes[0]) + iterator_node_ids = self.get_node_iterators(source_node_id, iterator_graph) if isinstance(self._state.graph.get_node(source_node_id), IterateInvocation): iterator_node_ids.append(source_node_id) @@ -2187,6 +2213,44 @@ def _should_defer_sibling_if_collect_iterate(self, node_id: str) -> bool: if_exec_ids = self._state.source_prepared_mapping.get(if_node_id, set()) return not if_exec_ids or not all(exec_id in self._state._resolved_if_exec_branches for exec_id in if_exec_ids) + def _should_defer_shared_collect_if(self, node_id: str, iterator_ancestry: _IteratorAncestry) -> bool: + if not isinstance(self._state.graph.get_node(node_id), IfInvocation): + return False + condition_edges = self._state.graph._get_input_edges(node_id, "condition") + branch_edges = [self._state.graph._get_input_edges(node_id, field) for field in ("true_input", "false_input")] + if not condition_edges or any(not edges for edges in branch_edges): + return False + + axes = [ + { + axis_id + for edge in edges + for axis_id in (graph_if_runtime._get_edge_frame_axes(self._state, iterator_ancestry, edge, {}) or ()) + } + for edges in branch_edges + ] + condition_axes = { + axis_id for edge in condition_edges for axis_id in iterator_ancestry.get_frame_axes_for_edge(edge) + } + if axes[0] != axes[1] or not axes[0] > condition_axes: + return False + + branch_sources = [ + set().union( + *( + nx.ancestors(self._state._get_source_graph_flat(), edge.source.node_id) | {edge.source.node_id} + for edge in edges + ) + ) + for edges in branch_edges + ] + shared_collects = { + source_id + for source_id in branch_sources[0] & branch_sources[1] + if isinstance(self._state.graph.get_node(source_id), CollectInvocation) + } + return any(source_id not in self._state.executed for source_id in shared_collects) + def _expand_root_if_for_completed_collect_branch(self, graph: "nx.DiGraph") -> Optional[str]: """Align a root-conditioned If frame with each completed selected Collect frame.""" iterator_graph = self.iterator_graph(graph) @@ -2462,6 +2526,7 @@ def prepare(self, base_g: Optional["nx.DiGraph"] = None) -> Optional[str]: and self._is_if_condition_ready(node_id) or not isinstance(self._state.graph.get_node(node_id), IfInvocation) ) + and not self._should_defer_shared_collect_if(node_id, iterator_ancestry) and self._has_admitted_source_mapping(node_id, g, iterator_ancestry) and not self._should_defer_sibling_if_collect_iterate(node_id) and ( diff --git a/tests/app/services/shared/test_execution_engine_differential.py b/tests/app/services/shared/test_execution_engine_differential.py index 858a9f2787a..dcbffd3c85b 100644 --- a/tests/app/services/shared/test_execution_engine_differential.py +++ b/tests/app/services/shared/test_execution_engine_differential.py @@ -3647,6 +3647,82 @@ def test_wired_root_condition_with_empty_for_parent_frame_completes( assert not {"if", "for", "ret", "fallback"} & set(trace) +@pytest.mark.parametrize("force_compatibility_scheduler", [False, True], ids=["default", "compatibility"]) +def test_wired_root_condition_with_empty_for_output_through_any_node_completes( + force_compatibility_scheduler: bool, +) -> None: + graph = _graph_from( + [ + CollectionConcatInvocation(id="src", first=[]), + IterateInvocation(id="iterate"), + ForInvocation(id="for"), + AnyTypeTestInvocation(id="for_output"), + ForReturnInvocation(id="ret"), + FixedCollectionTestInvocation(id="condition", value=True), + AnyTypeTestInvocation(id="fallback", value="fb"), + IfInvocation(id="if"), + CollectInvocation(id="collect"), + ], + [ + ("src", "collection", "iterate", "collection"), + ("iterate", "item", "for", "collection"), + ("for", "output_collection", "for_output", "value"), + ("for_output", "value", "if", "true_input"), + ("for", "item", "ret", "output"), + create_loop_linkage("for", "ret"), + ("condition", "condition", "if", "condition"), + ("fallback", "value", "if", "false_input"), + ("if", "value", "collect", "item"), + ], + ) + state = GraphExecutionState(graph=graph) + trace, state = _run_graph(state, force_compatibility_scheduler=force_compatibility_scheduler) + + assert not state.has_error(), state.errors + assert state.is_complete(), f"graph stalled after {trace}" + assert _source_values(state, "collect") == {(): []} + assert not {"if", "for", "for_output", "ret", "fallback"} & set(trace) + + +@pytest.mark.parametrize("force_compatibility_scheduler", [False, True], ids=["default", "compatibility"]) +def test_wired_per_item_condition_with_empty_for_output_completes( + force_compatibility_scheduler: bool, +) -> None: + graph = _graph_from( + [ + CollectionConcatInvocation(id="src", first=[]), + IterateInvocation(id="iterate"), + ForInvocation(id="for"), + AnyTypeTestInvocation(id="for_output"), + ForReturnInvocation(id="ret"), + FixedCollectionTestInvocation(id="condition"), + AnyTypeTestInvocation(id="fallback", value="fb"), + IfInvocation(id="if"), + CollectInvocation(id="collect"), + ], + [ + ("src", "collection", "iterate", "collection"), + ("iterate", "item", "for", "collection"), + ("for", "output_collection", "for_output", "value"), + ("for_output", "value", "if", "true_input"), + ("for", "item", "ret", "output"), + create_loop_linkage("for", "ret"), + ("iterate", "item", "condition", "value"), + ("condition", "condition", "if", "condition"), + ("fallback", "value", "if", "false_input"), + ("if", "value", "collect", "item"), + ], + ) + state = GraphExecutionState(graph=graph) + trace, state = _run_graph(state, force_compatibility_scheduler=force_compatibility_scheduler) + + assert not state.has_error(), state.errors + assert state.is_complete(), f"graph stalled after {trace}" + assert _source_values(state, "collect") == {(): []} + assert trace[0] == "src" + assert not {"condition", "if", "for", "for_output", "ret", "fallback"} & set(trace) + + @pytest.mark.parametrize( "force_compatibility_scheduler", [False, True], ids=["default-route", "forced-compatibility-route"] ) @@ -3985,6 +4061,107 @@ def _per_item_condition_with_collect_of_collects_branch_graph() -> Graph: return _graph_from(nodes, edges) +def _outer_condition_with_two_collects_graph( + *, share_branch_axes: bool, passthrough_between_collects: bool = False +) -> Graph: + nodes: list[Any] = [ + CollectionConcatInvocation(id="source", first=[[[["a"], ["b"]], [["c"]]], [], [[["d", "e"]]]]), + ] + edges: list[Any] = [] + previous = ("source", "collection") + for axis_id in ("outer", "middle", "deep", "inner"): + nodes.append(IterateInvocation(id=axis_id)) + edges.append((*previous, axis_id, "collection")) + if axis_id != "inner": + concat_id = f"{axis_id}_items" + nodes.append(CollectionConcatInvocation(id=concat_id)) + edges.append((axis_id, "item", concat_id, "first")) + previous = (concat_id, "collection") + nodes += [ + MarkedAnyTypeTestInvocation(id="body", marker="b:"), + CollectInvocation(id="inner_collect"), + CollectInvocation(id="middle_collect"), + FixedCollectionTestInvocation(id="condition"), + IfInvocation(id="if"), + CollectInvocation(id="collect"), + ] + edges += [ + ("inner", "item", "body", "value"), + ("body", "value", "inner_collect", "item"), + ("outer", "item", "condition", "value"), + ("condition", "condition", "if", "condition"), + ("if", "value", "collect", "item"), + ] + if passthrough_between_collects: + nodes.append(AnyTypeTestInvocation(id="collect_passthrough")) + edges.append(("inner_collect", "collection", "collect_passthrough", "value")) + edges.append(("collect_passthrough", "value", "middle_collect", "item")) + else: + edges.append(("inner_collect", "collection", "middle_collect", "item")) + edges.append(("middle_collect", "collection", "if", "true_input")) + if share_branch_axes: + nodes.append(MarkedAnyTypeTestInvocation(id="false_copy", marker="f:")) + edges += [ + ("middle_collect", "collection", "false_copy", "value"), + ("false_copy", "value", "if", "false_input"), + ] + else: + nodes.append(AnyTypeTestInvocation(id="fallback", value="root")) + edges.append(("fallback", "value", "if", "false_input")) + return _graph_from(nodes, edges) + + +@pytest.mark.parametrize("force_compatibility_scheduler", [False, True], ids=["default", "compatibility"]) +def test_per_item_if_rejects_asymmetric_collect_chain_before_invocation( + force_compatibility_scheduler: bool, +) -> None: + state = GraphExecutionState(graph=_outer_condition_with_two_collects_graph(share_branch_axes=False)) + if force_compatibility_scheduler: + state._execution_scheduler = _ExecutionScheduler(state) + + with pytest.raises(graph_if_runtime.UnsupportedIfIterationAxesError, match="deeper open iterator axes"): + state.next() + assert state.executed_history == [] + + +@pytest.mark.parametrize("force_compatibility_scheduler", [False, True], ids=["default", "compatibility"]) +def test_per_item_if_accepts_shared_collect_chain_axes_and_completes( + force_compatibility_scheduler: bool, +) -> None: + state = GraphExecutionState(graph=_outer_condition_with_two_collects_graph(share_branch_axes=True)) + trace, state = _run_graph(state, force_compatibility_scheduler=force_compatibility_scheduler) + + assert not state.has_error(), state.errors + assert state.is_complete(), f"graph stalled after {trace}" + assert _source_values(state, "collect") == { + (0,): [[["b:a"], ["b:b"]], [["b:c"]]], + (1,): [], + (2,): [[["b:d", "b:e"]]], + } + assert trace.count("if") == 3 + assert "false_copy" not in trace + + +@pytest.mark.parametrize("force_compatibility_scheduler", [False, True], ids=["default", "compatibility"]) +def test_per_item_if_preserves_collect_axes_through_passthrough_and_completes( + force_compatibility_scheduler: bool, +) -> None: + state = GraphExecutionState( + graph=_outer_condition_with_two_collects_graph(share_branch_axes=True, passthrough_between_collects=True) + ) + trace, state = _run_graph(state, force_compatibility_scheduler=force_compatibility_scheduler) + + assert not state.has_error(), state.errors + assert state.is_complete(), f"graph stalled after {trace}" + assert _source_values(state, "collect") == { + (0,): [[["b:a"], ["b:b"]], [["b:c"]]], + (1,): [], + (2,): [[["b:d", "b:e"]]], + } + assert trace.count("if") == 3 + assert "false_copy" not in trace + + def _reopen_of_a_cross_product_graph(swap_add_inputs: bool, condition: bool = False) -> Graph: """A literal If chooses between an Iterate reopening a Collect over two crossed axes and the crossed inner axis.""" first_axis, second_axis = ("right", "left") if swap_add_inputs else ("left", "right") From 21a00151860d28f43b99f0d225cd7fe588128224 Mon Sep 17 00:00:00 2001 From: JPPhoto Date: Sun, 25 Oct 2026 11:17:09 -0500 Subject: [PATCH 3/7] refactor(graph): trim If execution helpers Remove unused compatibility activation queries and consolidate Collect-axis and input-edge ordering logic. Preserve scheduler-specific comparisons and For-final ancestry semantics. --- .../services/shared/graph_if_activation.py | 54 ++----------------- .../app/services/shared/graph_if_runtime.py | 42 +++++++++------ .../shared/graph_iterator_ancestry.py | 28 +++++----- .../app/services/shared/graph_materializer.py | 38 +++---------- .../shared/test_graph_if_activation.py | 39 +------------- .../services/shared/test_graph_if_runtime.py | 28 +++++++++- 6 files changed, 81 insertions(+), 148 deletions(-) diff --git a/invokeai/app/services/shared/graph_if_activation.py b/invokeai/app/services/shared/graph_if_activation.py index 34d6c366063..2a996c98c09 100644 --- a/invokeai/app/services/shared/graph_if_activation.py +++ b/invokeai/app/services/shared/graph_if_activation.py @@ -9,11 +9,11 @@ class _IfActivationController: - """Own activation admission for concrete execution nodes. + """Compile activation dependencies for source nodes on compatibility paths. Unlike the legacy compiler, this controller does not lower or cache a - branch projection. It answers one readiness question for one prepared - execution node from the current source graph and durable execution frame. + branch projection. It derives frame-scoped branch requirements from the + current source graph for the fallback scheduler. """ def __init__(self, state: "GraphExecutionState") -> None: @@ -128,14 +128,6 @@ def _branch_memberships(self, source_node_id: str) -> tuple[tuple[str, tuple[str self._branch_memberships_by_source[source_node_id] = memberships return memberships - def get_dependencies(self, exec_node_id: str) -> tuple[ActivationDependency, ...]: - """Return activation requirements for one concrete prepared node.""" - - source_node_id = self._state._prepared_registry().get_source_node_id(exec_node_id) - if not self._get_if_node_ids(): - return () - return self.get_source_dependencies(source_node_id, self._state._get_iteration_path(exec_node_id)) - def get_source_dependencies( self, source_node_id: str, iteration_path: tuple[int, ...] = () ) -> tuple[ActivationDependency, ...]: @@ -146,43 +138,3 @@ def get_source_dependencies( for if_node_id, fields in self._branch_memberships(source_node_id) if len(fields) == 1 ) - - def is_source_admitted(self, source_node_id: str, iteration_path: tuple[int, ...] = ()) -> bool: - dependencies = self.get_source_dependencies(source_node_id, iteration_path) - return bool( - not dependencies - or all(self._state._is_activation_dependency_satisfied(dependency) for dependency in dependencies) - ) - - def is_source_rejected(self, source_node_id: str, iteration_path: tuple[int, ...] = ()) -> bool: - return any( - self._state._is_activation_dependency_rejected(dependency) - for dependency in self.get_source_dependencies(source_node_id, iteration_path) - ) - - def is_source_inactive(self, source_node_id: str, iteration_path: tuple[int, ...] = ()) -> bool: - """Return whether a source belongs exclusively to a rejected If branch.""" - - if iteration_path: - return self.is_source_rejected(source_node_id, iteration_path) - - relevant_if_ids = {if_node_id for if_node_id, _ in self._branch_memberships(source_node_id)} - if not relevant_if_ids: - return False - - frames: set[tuple[int, ...]] = set() - for if_node_id in relevant_if_ids: - frames.update( - self._state._get_iteration_path(exec_node_id) - for exec_node_id in self._state._prepared_registry().get_prepared_ids(if_node_id) - ) - if not frames: - return False - - for frame in frames: - dependencies = self.get_source_dependencies(source_node_id, frame) - if dependencies and not any( - self._state._is_activation_dependency_rejected(dependency) for dependency in dependencies - ): - return False - return True diff --git a/invokeai/app/services/shared/graph_if_runtime.py b/invokeai/app/services/shared/graph_if_runtime.py index 27a5fecf8b4..b6b0889d743 100644 --- a/invokeai/app/services/shared/graph_if_runtime.py +++ b/invokeai/app/services/shared/graph_if_runtime.py @@ -199,19 +199,19 @@ def _validate_collect_branch_axes( condition_axes = set(iterator_ancestry.get_frame_axes_for_edge(condition_edge)) if not condition_axes: return - branch_axes: dict[str, set[str]] = {} + branch_axes = { + branch_field: set(axes) + for branch_field, axes in _get_if_branch_frame_axes(state, iterator_ancestry, node_id, frame_axes_cache).items() + } branch_collect_sources: dict[str, set[str]] = {} source_graph = state._get_source_graph_flat() for branch_field in ("true_input", "false_input"): - axes: set[str] = set() collect_sources: set[str] = set() for edge in state.graph._get_input_edges(node_id, branch_field): - axes.update(_get_edge_frame_axes(state, iterator_ancestry, edge, frame_axes_cache) or ()) ancestors = nx.ancestors(source_graph, edge.source.node_id) | {edge.source.node_id} collect_sources.update( source_id for source_id in ancestors if isinstance(state.graph.get_node(source_id), CollectInvocation) ) - branch_axes[branch_field] = axes branch_collect_sources[branch_field] = collect_sources for branch_field, other_field in (("true_input", "false_input"), ("false_input", "true_input")): axes = branch_axes[branch_field] @@ -314,6 +314,24 @@ def _get_edge_frame_axes( return frame_axes_cache[edge.source.node_id] +def _get_if_branch_frame_axes( + state: "GraphExecutionState", + iterator_ancestry: _IteratorAncestry, + node_id: str, + frame_axes_cache: dict[str, Optional[tuple[str, ...]]], +) -> dict[str, tuple[str, ...]]: + """Return each If branch's ordered open axes, resolving Collect-backed sources once per call.""" + result: dict[str, tuple[str, ...]] = {} + for branch in ("true_input", "false_input"): + axes: list[str] = [] + for edge in state.graph._get_input_edges(node_id, branch): + for axis_id in _get_edge_frame_axes(state, iterator_ancestry, edge, frame_axes_cache) or (): + if axis_id not in axes: + axes.append(axis_id) + result[branch] = tuple(axes) + return result + + def _get_effective_iteration_paths_for_edge( state: "GraphExecutionState", edge: Edge, @@ -373,14 +391,9 @@ def _get_effective_iteration_paths_for_edge( iterator_ancestry, cache, ) - group_depth = len(iterator_ancestry.get_iterator_axes_for_edge(input_edge)) - input_source = state.graph.get_node(input_edge.source.node_id) - # A For's final output already excludes the For's own axis, so the Collect closes nothing more. - is_for_final = isinstance(input_source, ForInvocation) and ( - get_output_field_scope(input_source, input_edge.source.field) == OutputScope.Final + group_depth = len( + _without_closed_axis(state, input_edge, iterator_ancestry.get_iterator_axes_for_edge(input_edge)) ) - if input_edge.destination.field == ITEM_FIELD and not is_for_final: - group_depth = max(group_depth - 1, 0) collapsed_paths.update(path[:group_depth] for path in input_paths) cache[cache_key] = collapsed_paths or ({()} if not input_edges else set()) continue @@ -429,12 +442,7 @@ def _get_if_iteration_paths_with_ancestry( # A filtered materialization view can omit unresolved condition sources. continue branch_axes = { - branch: { - axis_id - for edge in state.graph._get_input_edges(node_id, branch) - for axis_id in (_get_edge_frame_axes(state, iterator_ancestry, edge, {}) or ()) - } - for branch in ("true_input", "false_input") + branch: set(axes) for branch, axes in _get_if_branch_frame_axes(state, iterator_ancestry, node_id, {}).items() } shared_deeper_branch_axes = ( bool(branch_axes["true_input"]) diff --git a/invokeai/app/services/shared/graph_iterator_ancestry.py b/invokeai/app/services/shared/graph_iterator_ancestry.py index e28570dbe43..e2ab99d555c 100644 --- a/invokeai/app/services/shared/graph_iterator_ancestry.py +++ b/invokeai/app/services/shared/graph_iterator_ancestry.py @@ -3,12 +3,23 @@ from invokeai.app.invocations.fields import OutputScope from invokeai.app.invocations.loops import ForInvocation from invokeai.app.services.shared.graph_models import Edge -from invokeai.app.services.shared.graph_validation import IterateInvocation, get_output_field_scope, nx +from invokeai.app.services.shared.graph_validation import Graph, IterateInvocation, get_output_field_scope, nx if TYPE_CHECKING: from invokeai.app.services.shared.graph import GraphExecutionState +def _get_ordered_input_edges(graph: Graph, input_edges: list[Edge]) -> list[Edge]: + if not input_edges: + return [] + destination = graph.get_node(input_edges[0].destination.node_id) + field_order = {field: index for index, field in enumerate(type(destination).model_fields)} + return sorted( + input_edges, + key=lambda edge: (field_order.get(edge.destination.field, len(field_order)), edge.source.node_id), + ) + + class _IteratorAncestry: """Own the ordered open For/Iterate axes for one source-graph traversal.""" @@ -76,12 +87,8 @@ def get_ordered_iterator_ancestors(self, node_id: str) -> list[str]: continue visited.add(current_node_id) pending.append((current_node_id, True)) - current_node = self._state.graph.get_node(current_node_id) - input_fields = list(type(current_node).model_fields) - field_order = {field: index for index, field in enumerate(input_fields)} - input_edges = sorted( - self._state.graph._get_input_edges(current_node_id), - key=lambda edge: (field_order.get(edge.destination.field, len(field_order)), edge.source.node_id), + input_edges = _get_ordered_input_edges( + self._state.graph, self._state.graph._get_input_edges(current_node_id) ) for edge in reversed(input_edges): source_node_id = edge.source.node_id @@ -136,11 +143,8 @@ def get_frame_iterators(self, node_id: str) -> tuple[str, ...]: continue visited.add((current_node_id, opens)) pending.append((current_node_id, True, opens)) - current_node = self._state.graph.get_node(current_node_id) - field_order = {field: index for index, field in enumerate(type(current_node).model_fields)} - input_edges = sorted( - self._state.graph._get_input_edges(current_node_id), - key=lambda edge: (field_order.get(edge.destination.field, len(field_order)), edge.source.node_id), + input_edges = _get_ordered_input_edges( + self._state.graph, self._state.graph._get_input_edges(current_node_id) ) for edge in reversed(input_edges): source_node_id = edge.source.node_id diff --git a/invokeai/app/services/shared/graph_materializer.py b/invokeai/app/services/shared/graph_materializer.py index ace71c25e2f..60492d3b493 100644 --- a/invokeai/app/services/shared/graph_materializer.py +++ b/invokeai/app/services/shared/graph_materializer.py @@ -15,7 +15,7 @@ ) from invokeai.app.services.shared import graph_if_runtime from invokeai.app.services.shared.execution_engine.primitives import ActivationGate -from invokeai.app.services.shared.graph_iterator_ancestry import _IteratorAncestry +from invokeai.app.services.shared.graph_iterator_ancestry import _get_ordered_input_edges, _IteratorAncestry from invokeai.app.services.shared.graph_loop_topology import NestedForBody, NestedIterateBody, NestedIterateChain from invokeai.app.services.shared.graph_models import Edge, EdgeConnection from invokeai.app.services.shared.graph_validation import ( @@ -99,15 +99,7 @@ def _get_ordered_input_iterator_axes(self, input_edges: list[Edge]) -> tuple[str if not input_edges: return () - destination = self._state.graph.get_node(input_edges[0].destination.node_id) - input_field_order = {field: index for index, field in enumerate(type(destination).model_fields)} - ordered_edges = sorted( - input_edges, - key=lambda edge: ( - input_field_order.get(edge.destination.field, len(input_field_order)), - edge.source.node_id, - ), - ) + ordered_edges = _get_ordered_input_edges(self._state.graph, input_edges) axes: list[str] = [] for edge in ordered_edges: for axis_id in self._get_iteration_axes(edge.source.node_id): @@ -1258,18 +1250,10 @@ def _get_collect_source_iterator_ids( ) -> list[str]: if isinstance(self._state.graph.get_node(source_node_id), IfInvocation): iterator_ancestry = self._get_iterator_ancestry(iterator_graph or self.iterator_graph()) - branch_axes = [ - tuple( - dict.fromkeys( - axis_id - for edge in self._state.graph._get_input_edges(source_node_id, branch) - for axis_id in ( - graph_if_runtime._get_edge_frame_axes(self._state, iterator_ancestry, edge, {}) or () - ) - ) - ) - for branch in ("true_input", "false_input") - ] + branch_axes_by_field = graph_if_runtime._get_if_branch_frame_axes( + self._state, iterator_ancestry, source_node_id, {} + ) + branch_axes = [branch_axes_by_field[branch] for branch in ("true_input", "false_input")] condition_axes = { axis_id for edge in self._state.graph._get_input_edges(source_node_id, "condition") @@ -2221,14 +2205,8 @@ def _should_defer_shared_collect_if(self, node_id: str, iterator_ancestry: _Iter if not condition_edges or any(not edges for edges in branch_edges): return False - axes = [ - { - axis_id - for edge in edges - for axis_id in (graph_if_runtime._get_edge_frame_axes(self._state, iterator_ancestry, edge, {}) or ()) - } - for edges in branch_edges - ] + branch_axes = graph_if_runtime._get_if_branch_frame_axes(self._state, iterator_ancestry, node_id, {}) + axes = [set(branch_axes[field]) for field in ("true_input", "false_input")] condition_axes = { axis_id for edge in condition_edges for axis_id in iterator_ancestry.get_frame_axes_for_edge(edge) } diff --git a/tests/app/services/shared/test_graph_if_activation.py b/tests/app/services/shared/test_graph_if_activation.py index 150bc985b93..2c3d4ea38ff 100644 --- a/tests/app/services/shared/test_graph_if_activation.py +++ b/tests/app/services/shared/test_graph_if_activation.py @@ -46,35 +46,13 @@ def _get_output_edges(self, node_id: str) -> list[Any]: return self._output_edges.get(node_id, []) -class _Registry: - def get_source_node_id(self, exec_node_id: str) -> str: - return exec_node_id - - def get_prepared_ids(self, source_node_id: str) -> list[str]: - return [source_node_id] if source_node_id == "if" else [] - - class _State: def __init__(self) -> None: self.graph = _Graph() - self.admitted: set[ActivationDependency] = set() - self.rejected: set[ActivationDependency] = set() - - def _prepared_registry(self) -> _Registry: - return _Registry() - - def _get_iteration_path(self, _: str) -> tuple[int, ...]: - return () def _get_source_graph_flat(self) -> object: return object() - def _is_activation_dependency_satisfied(self, dependency: ActivationDependency) -> bool: - return dependency in self.admitted - - def _is_activation_dependency_rejected(self, dependency: ActivationDependency) -> bool: - return dependency in self.rejected - @pytest.mark.platform_independent def test_standalone_import_does_not_import_graph() -> None: @@ -101,7 +79,7 @@ def ancestors(self, _graph: object, node_id: str) -> set[str]: assert calls == ["true"] -def test_controller_dependencies_admission_rejection_and_inactive(monkeypatch: pytest.MonkeyPatch) -> None: +def test_controller_gets_source_dependencies(monkeypatch: pytest.MonkeyPatch) -> None: class _NetworkX: def ancestors(self, _graph: object, _node_id: str) -> set[str]: return set() @@ -115,17 +93,6 @@ def ancestors(self, _graph: object, _node_id: str) -> set[str]: assert controller.get_source_dependencies("true") == (true_dependency,) assert controller.get_source_dependencies("false") == (false_dependency,) assert controller.get_source_dependencies("outside") == () - assert not controller.is_source_admitted("true") - assert not controller.is_source_rejected("true") - assert not controller.is_source_inactive("true") - - state.admitted.add(true_dependency) - assert controller.is_source_admitted("true") - - state.rejected.add(true_dependency) - assert controller.is_source_rejected("true") - state.rejected.add(ActivationDependency(owner_id="if", branch="true_input", frame=(1,))) - assert controller.is_source_inactive("true", (1,)) @pytest.mark.parametrize( @@ -135,7 +102,7 @@ def ancestors(self, _graph: object, _node_id: str) -> set[str]: (False, "false", "true", "true_input"), ], ) -def test_controller_admits_only_source_level_selected_branch( +def test_controller_selects_source_dependencies_for_source_level_branch( monkeypatch: pytest.MonkeyPatch, condition: bool, selected_source: str, @@ -152,8 +119,6 @@ def ancestors(self, _graph: object, _node_id: str) -> set[str]: controller = _IfActivationController(state) assert controller.get_source_dependencies(selected_source) == () - assert controller.is_source_admitted(selected_source) - assert not controller.is_source_inactive(selected_source) assert controller.get_source_dependencies(unselected_source) == ( ActivationDependency(owner_id="if", branch=unselected_field, frame=()), ) diff --git a/tests/app/services/shared/test_graph_if_runtime.py b/tests/app/services/shared/test_graph_if_runtime.py index 47ae5d400e1..c126bff521d 100644 --- a/tests/app/services/shared/test_graph_if_runtime.py +++ b/tests/app/services/shared/test_graph_if_runtime.py @@ -1,6 +1,9 @@ +from types import SimpleNamespace + import pytest from invokeai.app.invocations.logic import BooleanLogicInvocation, IfInvocation +from invokeai.app.invocations.loops import ForInvocation from invokeai.app.invocations.math import AddInvocation from invokeai.app.invocations.primitives import BooleanOutput from invokeai.app.services.shared import graph_if_runtime @@ -8,7 +11,7 @@ from invokeai.app.services.shared.graph import Edge, EdgeConnection, Graph, GraphExecutionState from invokeai.app.services.shared.graph_iterator_ancestry import _IteratorAncestry from invokeai.app.services.shared.graph_runtime_records import _ApplyTransaction -from invokeai.app.services.shared.graph_validation import IterateInvocation +from invokeai.app.services.shared.graph_validation import CollectInvocation, IterateInvocation from tests.app.services.shared.import_test_utils import assert_module_imports_without_graph @@ -26,6 +29,29 @@ def test_runtime_import_without_graph() -> None: assert_module_imports_without_graph("invokeai.app.services.shared.graph_if_runtime") +@pytest.mark.parametrize( + ("source_kind", "source_field", "axes", "expected"), + [ + ("regular", "value", ("outer", "inner"), ("outer",)), + ("regular", "value", (), ()), + ("for_final", "output_collection", ("outer",), ("outer",)), + ], +) +def test_collect_item_axis_closure_handles_empty_axes_and_for_final_output( + source_kind: str, source_field: str, axes: tuple[str, ...], expected: tuple[str, ...] +) -> None: + source = IfInvocation(id="source", condition=True) if source_kind == "regular" else ForInvocation(id="source") + graph = Graph() + graph.nodes.update({"source": source, "collect": CollectInvocation(id="collect")}) + state = SimpleNamespace(graph=graph) + edge = Edge( + source=EdgeConnection(node_id="source", field=source_field), + destination=EdgeConnection(node_id="collect", field="item"), + ) + + assert graph_if_runtime._without_closed_axis(state, edge, axes) == expected + + @pytest.mark.parametrize( "field,value", [("state_id", "foreign"), ("frame_id", "foreign"), ("iteration_path", (3,)), ("workflow_call_depth", 1)], From fd80960bbf86063d26ea67d9e683ba6246759700 Mon Sep 17 00:00:00 2001 From: JPPhoto Date: Mon, 26 Oct 2026 07:46:25 -0500 Subject: [PATCH 4/7] fix(graph): resolve If frame review cases Handle nested For-final and empty Collect frame scheduling with correct root selection cardinality. Validate retained Collect axes, reject asymmetric branches, and keep passthrough traversal linear. --- invokeai/app/services/shared/README.md | 14 +- .../services/shared/graph_if_activation.py | 2 +- .../app/services/shared/graph_if_runtime.py | 260 ++++++++++---- .../app/services/shared/graph_materializer.py | 10 + .../test_execution_engine_differential.py | 318 +++++++++++++++++- .../services/shared/test_graph_if_runtime.py | 82 +++++ 6 files changed, 618 insertions(+), 68 deletions(-) diff --git a/invokeai/app/services/shared/README.md b/invokeai/app/services/shared/README.md index 5ef88e87258..a9aa4b10aa5 100644 --- a/invokeai/app/services/shared/README.md +++ b/invokeai/app/services/shared/README.md @@ -838,12 +838,14 @@ Run `C` -> `D:0` -> enqueue `D`. Run `D` -> done. the upstream axis over its reopening pairs it with per-group frames; both would drop or invent frames. - A branch whose open axes, or the axes a branch `Collect` keeps, are deeper than a per-item condition's, unless both branches carry the same deeper axes. Each `Collect.item` closes its innermost axis and keeps the outer axes; - this identity is followed through chained `Collect`s and ordinary passthrough nodes. A shared deeper axis lets the - `If` run once per available innermost frame. When both branches are Collect-backed, they must share a Collect in - that chain; matching axis names or equal collection lengths alone are insufficient. An asymmetric Collect chain - is rejected before invocation. A downstream `Collect` retains empty outer groups. - - A root-frame condition choosing between branches with different deeper axes. When both branches share nested - deeper axes, a root-frame condition runs the `If` once per frame of the innermost one. + this identity is followed through chained `Collect`s and ordinary passthrough nodes. Separate branch Collect chains + may retain the same axis identities and frames; sharing a Collect node is not required. Matching lengths alone do + not establish alignment. An asymmetric or distinct-axis Collect chain is rejected before invocation. A downstream + `Collect` retains empty outer groups. + - A root-frame condition choosing between branches with different deeper axes. When both branches retain the same + nested axes, a root-frame condition runs the `If` once per available frame of the innermost axis. Selecting a root + value over an axis reached only through an unselected `For` final output is the root-frame exception described + above; literal and computed root conditions have the same result cardinality. Selected-branch frame cardinality is not supported. - A completed `Collect` on a branch closes its item axis; the `If` does not wait for an `Iterate` that only that `Collect` consumes. When the `Collect` keeps an outer frame open and is selected, the `If` runs once per preserved diff --git a/invokeai/app/services/shared/graph_if_activation.py b/invokeai/app/services/shared/graph_if_activation.py index 2a996c98c09..ad9191f9c75 100644 --- a/invokeai/app/services/shared/graph_if_activation.py +++ b/invokeai/app/services/shared/graph_if_activation.py @@ -61,7 +61,7 @@ def _branch_sources(self, if_node_id: str, branch_field: str, source_graph: "nx. if ( for_id not in branch_sources or for_id in direct_sources - and not self._state.graph._get_input_edges(if_node_id, "condition") + and branch_field == self._get_source_level_selected_field(if_node_id) or any( for_id in body_ids_by_for[enclosing_id] and enclosing_id not in for_by_return_id.values() for enclosing_id in body_ids_by_for diff --git a/invokeai/app/services/shared/graph_if_runtime.py b/invokeai/app/services/shared/graph_if_runtime.py index b6b0889d743..1eaa1340666 100644 --- a/invokeai/app/services/shared/graph_if_runtime.py +++ b/invokeai/app/services/shared/graph_if_runtime.py @@ -203,22 +203,32 @@ def _validate_collect_branch_axes( branch_field: set(axes) for branch_field, axes in _get_if_branch_frame_axes(state, iterator_ancestry, node_id, frame_axes_cache).items() } - branch_collect_sources: dict[str, set[str]] = {} source_graph = state._get_source_graph_flat() + branch_collect_axes: dict[str, tuple[tuple[str, ...], ...]] = {} for branch_field in ("true_input", "false_input"): - collect_sources: set[str] = set() - for edge in state.graph._get_input_edges(node_id, branch_field): - ancestors = nx.ancestors(source_graph, edge.source.node_id) | {edge.source.node_id} - collect_sources.update( - source_id for source_id in ancestors if isinstance(state.graph.get_node(source_id), CollectInvocation) - ) - branch_collect_sources[branch_field] = collect_sources + branch_edges = state.graph._get_input_edges(node_id, branch_field) + branch_nodes = { + source_id + for edge in branch_edges + for source_id in nx.ancestors(source_graph, edge.source.node_id) | {edge.source.node_id} + } + retained_axes = set() + for collect_id in branch_nodes: + if not isinstance(state.graph.get_node(collect_id), CollectInvocation): + continue + for edge in state.graph._get_output_edges(collect_id, COLLECTION_FIELD): + if edge.destination.node_id not in branch_nodes | {node_id} or ( + edge.destination.node_id == node_id and edge.destination.field != branch_field + ): + continue + axes = _get_edge_frame_axes(state, iterator_ancestry, edge, frame_axes_cache) + if axes is not None and set(axes) > condition_axes: + retained_axes.add(tuple(axes)) + branch_collect_axes[branch_field] = tuple(sorted(retained_axes)) for branch_field, other_field in (("true_input", "false_input"), ("false_input", "true_input")): axes = branch_axes[branch_field] - both_collect_backed = bool(branch_collect_sources[branch_field]) and bool(branch_collect_sources[other_field]) - shared_collect_chain = bool(branch_collect_sources[branch_field] & branch_collect_sources[other_field]) if axes > condition_axes and ( - axes != branch_axes[other_field] or (both_collect_backed and not shared_collect_chain) + axes != branch_axes[other_field] or branch_collect_axes[branch_field] != branch_collect_axes[other_field] ): raise UnsupportedIfIterationAxesError( f"If node '{node_id}' cannot select branch input '{branch_field}' from deeper open iterator axes " @@ -258,59 +268,55 @@ def _get_edge_frame_axes( cached for one validation run; Collect chains are resolved with an explicit stack. """ source_node = state.graph.get_node(edge.source.node_id) - if not isinstance(source_node, CollectInvocation): - direct_axes = iterator_ancestry.get_frame_axes_for_edge(edge) - if isinstance(source_node, (IfInvocation, ForInvocation, IterateInvocation)): - return direct_axes - if edge.source.node_id in frame_axes_cache: - return frame_axes_cache[edge.source.node_id] - axes = set(direct_axes) - for input_edge in state.graph._get_input_edges(edge.source.node_id): - input_axes = _get_edge_frame_axes(state, iterator_ancestry, input_edge, frame_axes_cache) - if input_axes is None: - frame_axes_cache[edge.source.node_id] = None - return None - axes.update(input_axes) - ordered_axes = tuple( - sorted(axes, key=lambda axis_id: (len(iterator_ancestry.get_frame_iterators(axis_id)), axis_id)) - ) - if not _is_nested_chain(iterator_ancestry, ordered_axes): - frame_axes_cache[edge.source.node_id] = None - return None - frame_axes_cache[edge.source.node_id] = ordered_axes - return ordered_axes - pending = [edge.source.node_id] + if isinstance(source_node, (IfInvocation, ForInvocation, IterateInvocation)): + return iterator_ancestry.get_frame_axes_for_edge(edge) + if edge.source.node_id in frame_axes_cache: + return frame_axes_cache[edge.source.node_id] + pending: list[tuple[Edge, bool]] = [(edge, False)] while pending: - collect_id = pending[-1] - if collect_id in frame_axes_cache: - pending.pop() + current_edge, exiting = pending.pop() + source_id = current_edge.source.node_id + if source_id in frame_axes_cache: continue - input_edges = state.graph._get_input_edges(collect_id) - unresolved = [ - input_edge.source.node_id - for input_edge in input_edges - if isinstance(state.graph.get_node(input_edge.source.node_id), CollectInvocation) - and input_edge.source.node_id not in frame_axes_cache - ] - if unresolved: - pending.extend(unresolved) + source_node = state.graph.get_node(source_id) + if isinstance(source_node, (IfInvocation, ForInvocation, IterateInvocation)): continue - pending.pop() - kept: Optional[set[str]] = set() + input_edges = state.graph._get_input_edges(source_id) + if not exiting: + pending.append((current_edge, True)) + pending.extend( + (input_edge, False) + for input_edge in reversed(input_edges) + if not isinstance( + state.graph.get_node(input_edge.source.node_id), (IfInvocation, ForInvocation, IterateInvocation) + ) + and input_edge.source.node_id not in frame_axes_cache + ) + continue + axes: Optional[set[str]] = set() for input_edge in input_edges: - input_axes = _get_edge_frame_axes(state, iterator_ancestry, input_edge, frame_axes_cache) + input_node = state.graph.get_node(input_edge.source.node_id) + input_axes = ( + iterator_ancestry.get_frame_axes_for_edge(input_edge) + if isinstance(input_node, (IfInvocation, ForInvocation, IterateInvocation)) + else frame_axes_cache[input_edge.source.node_id] + ) if input_axes is None: - kept = None + axes = None break - kept.update(_without_closed_axis(state, input_edge, input_axes)) - ordered = None - if kept is not None: - ordered = tuple( - sorted(kept, key=lambda axis_id: (len(iterator_ancestry.get_frame_iterators(axis_id)), axis_id)) - ) + if isinstance(source_node, CollectInvocation): + axes.update(_without_closed_axis(state, input_edge, input_axes)) + else: + axes.update(input_axes) + ordered = ( + None + if axes is None + else tuple(sorted(axes, key=lambda axis_id: (len(iterator_ancestry.get_frame_iterators(axis_id)), axis_id))) + ) + if ordered is not None: if not _is_nested_chain(iterator_ancestry, ordered): ordered = None - frame_axes_cache[collect_id] = ordered + frame_axes_cache[source_id] = ordered return frame_axes_cache[edge.source.node_id] @@ -324,9 +330,11 @@ def _get_if_branch_frame_axes( result: dict[str, tuple[str, ...]] = {} for branch in ("true_input", "false_input"): axes: list[str] = [] + seen: set[str] = set() for edge in state.graph._get_input_edges(node_id, branch): for axis_id in _get_edge_frame_axes(state, iterator_ancestry, edge, frame_axes_cache) or (): - if axis_id not in axes: + if axis_id not in seen: + seen.add(axis_id) axes.append(axis_id) result[branch] = tuple(axes) return result @@ -377,6 +385,27 @@ def _get_effective_iteration_paths_for_edge( continue source_node = state.graph.get_node(current_source_id) + if ( + isinstance(source_node, ForInvocation) + and get_output_field_scope(source_node, current_edge.source.field) == OutputScope.Final + ): + frame_axes = iterator_ancestry.get_frame_axes_for_edge(current_edge) + if not frame_axes: + cache[cache_key] = {()} + continue + axis_id = frame_axes[-1] + axis_edges = state.graph._get_output_edges(axis_id, ITEM_FIELD) + cache[cache_key] = ( + { + get_prepared_edge_iteration_path(axis_edges[0], prepared_id) + for prepared_id in ( + get_prepared_nodes_for_source(axis_id) if axis_id in state.source_prepared_mapping else set() + ) + } + if axis_edges + else set() + ) + continue if isinstance(source_node, CollectInvocation) and current_edge.source.field == COLLECTION_FIELD: input_edges = state.graph._get_input_edges(current_source_id) collapsed_paths: set[tuple[int, ...]] = set() @@ -392,9 +421,34 @@ def _get_effective_iteration_paths_for_edge( cache, ) group_depth = len( - _without_closed_axis(state, input_edge, iterator_ancestry.get_iterator_axes_for_edge(input_edge)) + _without_closed_axis( + state, + input_edge, + _get_edge_frame_axes(state, iterator_ancestry, input_edge, {}) or (), + ) ) collapsed_paths.update(path[:group_depth] for path in input_paths) + if not collapsed_paths and input_edges: + for input_edge in input_edges: + group_axes = _without_closed_axis( + state, + input_edge, + _get_edge_frame_axes(state, iterator_ancestry, input_edge, {}) or (), + ) + if not group_axes: + collapsed_paths.add(()) + continue + axis_id = group_axes[-1] + axis_edges = state.graph._get_output_edges(axis_id, ITEM_FIELD) + if axis_edges: + collapsed_paths.update( + get_prepared_edge_iteration_path(axis_edges[0], prepared_id) + for prepared_id in ( + get_prepared_nodes_for_source(axis_id) + if axis_id in state.source_prepared_mapping + else set() + ) + ) cache[cache_key] = collapsed_paths or ({()} if not input_edges else set()) continue if isinstance(source_node, IfInvocation): @@ -408,6 +462,23 @@ def _get_effective_iteration_paths_for_edge( iterator_ancestry, ) ) + if not cache[cache_key] and current_edge.destination.field == ITEM_FIELD: + branch_axes = _get_if_branch_frame_axes(state, iterator_ancestry, current_source_id, {}) + if branch_axes["true_input"] == branch_axes["false_input"]: + group_axes = _without_closed_axis(state, current_edge, branch_axes["true_input"]) + if not group_axes: + cache[cache_key] = {()} + else: + axis_edges = state.graph._get_output_edges(group_axes[-1], ITEM_FIELD) + if axis_edges: + cache[cache_key] = { + get_prepared_edge_iteration_path(axis_edges[0], prepared_id) + for prepared_id in ( + get_prepared_nodes_for_source(group_axes[-1]) + if group_axes[-1] in state.source_prepared_mapping + else set() + ) + } continue input_edges = state.graph._get_input_edges(current_source_id) @@ -533,6 +604,14 @@ def _get_if_iteration_paths_with_ancestry( for branch_path in branch_paths if len(branch_path) > len(condition_path) and branch_path[: len(condition_path)] == condition_path } + if not branch_paths: + ordered_branch_axes = _get_if_branch_frame_axes(state, iterator_ancestry, node_id, {})[selected_field] + axis_edges = state.graph._get_output_edges(ordered_branch_axes[-1], ITEM_FIELD) + if axis_edges and ordered_branch_axes[-1] in state.source_prepared_mapping: + branch_paths = { + get_prepared_edge_iteration_path(axis_edges[0], prepared_id) + for prepared_id in get_prepared_nodes_for_source(ordered_branch_axes[-1]) + } for branch_path in branch_paths: if condition_path is None: @@ -710,6 +789,13 @@ def _is_source_inactive( frames = {frame for dependency in dependencies for frame in state._prepared_if_exec_frames(dependency.owner_id)} if not frames: + if any( + isinstance((owner := state.graph.get_node(dependency.owner_id)), IfInvocation) + and not state.graph._get_input_edges(dependency.owner_id, "condition") + and dependency.branch != ("true_input" if owner.condition else "false_input") + for dependency in dependencies + ): + return True return all(_has_empty_if_frame(state, dependency.owner_id) for dependency in dependencies) source_axes = set(state._runtime()._get_ordered_iterator_sources(source_node_id)) @@ -730,11 +816,67 @@ def _has_empty_if_frame(state: "GraphExecutionState", if_node_id: str) -> bool: if not isinstance(state.graph.nodes.get(if_node_id), IfInvocation): return False + ancestry = state._runtime()._iterator_ancestry + condition_edges = state.graph._get_input_edges(if_node_id, "condition") + axes = {axis_id for edge in condition_edges for axis_id in ancestry.get_frame_axes_for_edge(edge)} + branch_axes = _get_if_branch_frame_axes(state, ancestry, if_node_id, {}) + + selected_fields: set[str] = set() + if not condition_edges: + selected_fields.add("true_input" if state.graph.get_node(if_node_id).condition else "false_input") + else: + condition_edge = condition_edges[0] + for exec_id in state.source_prepared_mapping.get(condition_edge.source.node_id, ()): + if exec_id in state.results: + value = getattr(state.results[exec_id], condition_edge.source.field) + selected_fields.add("true_input" if value else "false_input") + + for branch_field, branch_axis_ids in branch_axes.items(): + branch_for_final_axes: set[str] = set() + pending = list(state.graph._get_input_edges(if_node_id, branch_field)) + visited: set[str] = set() + while pending: + edge = pending.pop() + source_id = edge.source.node_id + if source_id in visited: + continue + visited.add(source_id) + source_node = state.graph.get_node(source_id) + if ( + isinstance(source_node, ForInvocation) + and get_output_field_scope(source_node, edge.source.field) == OutputScope.Final + ): + branch_for_final_axes.update(ancestry.get_frame_axes_for_edge(edge)) + continue + pending.extend(state.graph._get_input_edges(source_id)) + if branch_field in selected_fields: + axes.update(branch_axis_ids) + else: + # Preserve the If's old empty-iterator behavior, but an unselected For final must not add frames. + axes.update(set(branch_axis_ids) - branch_for_final_axes) return any( isinstance(state.graph.get_node(iterator_id), IterateInvocation) and iterator_id in state.executed and not state.source_prepared_mapping.get(iterator_id) - for iterator_id in state._runtime()._iterator_ancestry.get_frame_iterators(if_node_id) + for iterator_id in axes + ) + + +def _has_unresolved_if_frame_axes( + state: "GraphExecutionState", if_node_id: str, iterator_ancestry: _IteratorAncestry +) -> bool: + condition_axes = { + axis_id + for edge in state.graph._get_input_edges(if_node_id, "condition") + for axis_id in iterator_ancestry.get_frame_axes_for_edge(edge) + } + branch_axes = _get_if_branch_frame_axes(state, iterator_ancestry, if_node_id, {}) + axes = branch_axes["true_input"] + return ( + bool(axes) + and axes == branch_axes["false_input"] + and set(axes) > condition_axes + and any(axis_id not in state.source_prepared_mapping and axis_id not in state.executed for axis_id in axes) ) diff --git a/invokeai/app/services/shared/graph_materializer.py b/invokeai/app/services/shared/graph_materializer.py index 60492d3b493..501e603a076 100644 --- a/invokeai/app/services/shared/graph_materializer.py +++ b/invokeai/app/services/shared/graph_materializer.py @@ -2505,6 +2505,10 @@ def prepare(self, base_g: Optional["nx.DiGraph"] = None) -> Optional[str]: or not isinstance(self._state.graph.get_node(node_id), IfInvocation) ) and not self._should_defer_shared_collect_if(node_id, iterator_ancestry) + and not ( + isinstance(self._state.graph.get_node(node_id), IfInvocation) + and graph_if_runtime._has_unresolved_if_frame_axes(self._state, node_id, iterator_ancestry) + ) and self._has_admitted_source_mapping(node_id, g, iterator_ancestry) and not self._should_defer_sibling_if_collect_iterate(node_id) and ( @@ -2565,6 +2569,12 @@ def prepare(self, base_g: Optional["nx.DiGraph"] = None) -> Optional[str]: or iteration_path[: len(condition_path or ())] == (condition_path or ()) ) ] + if not matching_mappings and not iteration_path: + matching_mappings = list( + self._get_parent_iteration_mappings( + next_node_id, g, input_edges=condition_edges, iterator_node_ids=[] + ) + ) for iteration_mapping in matching_mappings: create_results = self.create_execution_node( next_node_id, diff --git a/tests/app/services/shared/test_execution_engine_differential.py b/tests/app/services/shared/test_execution_engine_differential.py index dcbffd3c85b..1561d444471 100644 --- a/tests/app/services/shared/test_execution_engine_differential.py +++ b/tests/app/services/shared/test_execution_engine_differential.py @@ -3384,6 +3384,66 @@ def test_literal_if_selecting_collect_of_for_output_runs_per_outer_item() -> Non } == {(0,): [["a", "b"]], (1,): [["c"]]} +@pytest.mark.parametrize("condition", [True, False], ids=["true", "false"]) +@pytest.mark.parametrize("passthrough", [False, True], ids=["direct", "passthrough"]) +@pytest.mark.parametrize("force_compatibility_scheduler", [False, True], ids=["default", "compatibility"]) +def test_root_condition_selects_between_nested_for_final_branches( + condition: bool, passthrough: bool, force_compatibility_scheduler: bool +) -> None: + nodes: list[Any] = [ + CollectionConcatInvocation(id="source", first=[[["a", "b"], ["c"]], [["d"]]]), + IterateInvocation(id="outer"), + CollectionConcatInvocation(id="inner_source", first=[]), + IterateInvocation(id="inner"), + FixedCollectionTestInvocation(id="condition", value=condition), + IfInvocation(id="if"), + CollectInvocation(id="inner_collect"), + CollectInvocation(id="outer_collect"), + ] + edges: list[Any] = [ + ("source", "collection", "outer", "collection"), + ("outer", "item", "inner_source", "first"), + ("inner_source", "collection", "inner", "collection"), + ("condition", "condition", "if", "condition"), + ("if", "value", "inner_collect", "item"), + ("inner_collect", "collection", "outer_collect", "item"), + ] + for prefix, branch in (("yes", "true_input"), ("no", "false_input")): + nodes += [ + ForInvocation(id=f"{prefix}_for"), + MarkedAnyTypeTestInvocation(id=f"{prefix}_body", marker=f"{prefix}:"), + ForReturnInvocation(id=f"{prefix}_return"), + ] + edges += [ + ("inner", "item", f"{prefix}_for", "collection"), + (f"{prefix}_for", "item", f"{prefix}_body", "value"), + (f"{prefix}_body", "value", f"{prefix}_return", "output"), + create_loop_linkage(f"{prefix}_for", f"{prefix}_return"), + ] + if passthrough: + nodes.append(AnyTypeTestInvocation(id=f"{prefix}_pass")) + edges += [ + (f"{prefix}_for", "output_collection", f"{prefix}_pass", "value"), + (f"{prefix}_pass", "value", "if", branch), + ] + else: + edges.append((f"{prefix}_for", "output_collection", "if", branch)) + trace, state = _run_graph( + GraphExecutionState(graph=_graph_from(nodes, edges)), + force_compatibility_scheduler=force_compatibility_scheduler, + ) + + assert not state.has_error(), state.errors + assert state.is_complete(), f"graph stalled after {trace}" + marker = "yes:" if condition else "no:" + assert _source_values(state, "outer_collect") == { + (): [[[f"{marker}a", f"{marker}b"], [f"{marker}c"]], [[f"{marker}d"]]] + } + selected, rejected = ("yes", "no") if condition else ("no", "yes") + assert f"{selected}_for" in trace + assert f"{rejected}_for" not in trace + + @pytest.mark.parametrize("fail_unselected", [False, True], ids=["succeeds", "unselected-fails"]) def test_per_item_if_does_not_run_for_output_branch_for_unselected_item(fail_unselected: bool) -> None: """A For whose final output is collected into one If branch runs, with its body, only for selecting items.""" @@ -4062,10 +4122,16 @@ def _per_item_condition_with_collect_of_collects_branch_graph() -> Graph: def _outer_condition_with_two_collects_graph( - *, share_branch_axes: bool, passthrough_between_collects: bool = False + *, + share_branch_axes: bool, + passthrough_between_collects: bool = False, + source_values: list[Any] | None = None, ) -> Graph: nodes: list[Any] = [ - CollectionConcatInvocation(id="source", first=[[[["a"], ["b"]], [["c"]]], [], [[["d", "e"]]]]), + CollectionConcatInvocation( + id="source", + first=source_values if source_values is not None else [[[["a"], ["b"]], [["c"]]], [], [[["d", "e"]]]], + ), ] edges: list[Any] = [] previous = ("source", "collection") @@ -4111,6 +4177,213 @@ def _outer_condition_with_two_collects_graph( return _graph_from(nodes, edges) +def _separate_collect_branches_graph( + *, shape: str, unrelated_root_collect: bool, unrelated_root_collect_branch: str | None = None +) -> Graph: + nodes: list[Any] = [ + CollectionConcatInvocation(id="source", first=[[["a", "b"], ["c"]], [["d"]]]), + IterateInvocation(id="outer"), + CollectionConcatInvocation(id="middle_source"), + IterateInvocation(id="middle"), + CollectionConcatInvocation(id="deep_source"), + IterateInvocation(id="deep"), + FixedCollectionTestInvocation(id="condition", value=True), + IfInvocation(id="if"), + CollectInvocation(id="collect"), + ] + edges: list[Any] = [ + ("source", "collection", "outer", "collection"), + ("outer", "item", "middle_source", "first"), + ("middle_source", "collection", "middle", "collection"), + ("middle", "item", "deep_source", "first"), + ("deep_source", "collection", "deep", "collection"), + ("outer", "item", "condition", "value"), + ("condition", "condition", "if", "condition"), + ("if", "value", "collect", "item"), + ] + nodes += [MarkedAnyTypeTestInvocation(id="true_body", marker="true:"), CollectInvocation(id="true_collect")] + edges += [ + ("deep", "item", "true_body", "value"), + ("true_body", "value", "true_collect", "item"), + ] + if not unrelated_root_collect and unrelated_root_collect_branch != "true_input": + edges.append(("true_collect", "collection", "if", "true_input")) + if shape == "distinct-inner-axis": + nodes += [ + CollectionConcatInvocation(id="middle2_source"), + IterateInvocation(id="middle2"), + CollectionConcatInvocation(id="deep2_source"), + IterateInvocation(id="deep2"), + MarkedAnyTypeTestInvocation(id="false_body", marker="false:"), + CollectInvocation(id="false_collect"), + ] + edges += [ + ("outer", "item", "middle2_source", "first"), + ("middle2_source", "collection", "middle2", "collection"), + ("middle2", "item", "deep2_source", "first"), + ("deep2_source", "collection", "deep2", "collection"), + ("deep2", "item", "false_body", "value"), + ("false_body", "value", "false_collect", "item"), + ] + false_source = "false_collect" + if not unrelated_root_collect and unrelated_root_collect_branch != "false_input": + edges.append((false_source, "collection", "if", "false_input")) + else: + nodes += [MarkedAnyTypeTestInvocation(id="false_body", marker="false:"), CollectInvocation(id="false_collect")] + edges += [ + ("deep", "item", "false_body", "value"), + ("false_body", "value", "false_collect", "item"), + ] + if shape == "asymmetric": + nodes.append(CollectInvocation(id="false_outer_collect")) + edges += [ + ("false_collect", "collection", "false_outer_collect", "item"), + ] + false_source = "false_outer_collect" + else: + false_source = "false_collect" + if not unrelated_root_collect and unrelated_root_collect_branch != "false_input": + edges.append((false_source, "collection", "if", "false_input")) + if unrelated_root_collect or unrelated_root_collect_branch: + nodes += [ + CollectionConcatInvocation(id="unrelated_source", first=[]), + IterateInvocation(id="unrelated_iterate"), + AnyTypeTestInvocation(id="unrelated_body"), + CollectInvocation(id="unrelated_collect"), + ] + edges += [ + ("unrelated_source", "collection", "unrelated_iterate", "collection"), + ("unrelated_iterate", "item", "unrelated_body", "value"), + ("unrelated_body", "value", "unrelated_collect", "item"), + ] + append_fields = ( + {"true_input", "false_input"} + if unrelated_root_collect + else {unrelated_root_collect_branch} + if unrelated_root_collect_branch + else set() + ) + for branch_field, branch_source in (("true_input", "true_collect"), ("false_input", false_source)): + if branch_field in append_fields: + append_id = f"{branch_field}_append" + nodes.append(CollectionConcatInvocation(id=append_id)) + edges += [ + (branch_source, "collection", append_id, "first"), + ("unrelated_collect", "collection", append_id, "second"), + (append_id, "collection", "if", branch_field), + ] + return _graph_from(nodes, edges) + + +@pytest.mark.parametrize("unrelated_root_collect", [False, True], ids=["plain", "unrelated-collect"]) +@pytest.mark.parametrize("force_compatibility_scheduler", [False, True], ids=["default", "compatibility"]) +def test_if_accepts_separate_collects_retaining_matching_axes( + unrelated_root_collect: bool, force_compatibility_scheduler: bool +) -> None: + state = GraphExecutionState( + graph=_separate_collect_branches_graph(shape="matching", unrelated_root_collect=unrelated_root_collect) + ) + trace, state = _run_graph(state, force_compatibility_scheduler=force_compatibility_scheduler) + + assert not state.has_error(), state.errors + assert state.is_complete(), f"graph stalled after {trace}" + assert _source_values(state, "collect") == { + (0,): [["true:a", "true:b"], ["true:c"]], + (1,): [["true:d"]], + } + assert trace.count("if") == 3 + assert not any(source_id == "false_body" for source_id in trace) + + +@pytest.mark.parametrize("unrelated_root_collect_branch", ["true_input", "false_input"]) +@pytest.mark.parametrize("force_compatibility_scheduler", [False, True], ids=["default", "compatibility"]) +def test_if_accepts_matching_collect_axes_with_one_sided_unrelated_root_collect( + unrelated_root_collect_branch: str, force_compatibility_scheduler: bool +) -> None: + state = GraphExecutionState( + graph=_separate_collect_branches_graph( + shape="matching", unrelated_root_collect=False, unrelated_root_collect_branch=unrelated_root_collect_branch + ) + ) + trace, state = _run_graph(state, force_compatibility_scheduler=force_compatibility_scheduler) + + assert not state.has_error(), state.errors + assert state.is_complete(), f"graph stalled after {trace}" + assert _source_values(state, "collect") == { + (0,): [["true:a", "true:b"], ["true:c"]], + (1,): [["true:d"]], + } + assert trace.count("if") == 3 + assert "unrelated_body" not in trace + + +@pytest.mark.parametrize("shape", ["asymmetric", "distinct-inner-axis"], ids=["O-M-vs-O", "O-M-vs-O-M2"]) +@pytest.mark.parametrize("unrelated_root_collect", [False, True], ids=["plain", "unrelated-collect"]) +@pytest.mark.parametrize("force_compatibility_scheduler", [False, True], ids=["default", "compatibility"]) +def test_if_rejects_separate_collects_with_different_axes_before_invocation( + shape: str, unrelated_root_collect: bool, force_compatibility_scheduler: bool +) -> None: + state = GraphExecutionState( + graph=_separate_collect_branches_graph(shape=shape, unrelated_root_collect=unrelated_root_collect) + ) + if force_compatibility_scheduler: + state._execution_scheduler = _ExecutionScheduler(state) + + with pytest.raises(graph_if_runtime.UnsupportedIfIterationAxesError): + state.next() + assert state.executed_history == [] + + +@pytest.mark.parametrize("force_compatibility_scheduler", [False, True], ids=["default", "compatibility"]) +@pytest.mark.parametrize( + ("source_values", "expected", "if_count"), + [([[]], {(0,): []}, 0), ([[], []], {(0,): [], (1,): []}, 0), ([[[]], [[]]], {(0,): [[[]]], (1,): [[[]]]}, 2)], + ids=["empty-outer", "empty-outer-frames", "empty-deep-groups"], +) +def test_shared_collect_chain_completes_empty_groups( + force_compatibility_scheduler: bool, source_values: list[Any], expected: dict[tuple[int, ...], Any], if_count: int +) -> None: + state = GraphExecutionState( + graph=_outer_condition_with_two_collects_graph(share_branch_axes=True, source_values=source_values) + ) + trace, state = _run_graph(state, force_compatibility_scheduler=force_compatibility_scheduler) + + assert not state.has_error(), state.errors + assert state.is_complete(), f"graph stalled after {trace}" + assert _source_values(state, "collect") == expected + assert trace.count("if") == if_count + assert "false_copy" not in trace + + +@pytest.mark.parametrize("force_compatibility_scheduler", [False, True], ids=["default", "compatibility"]) +def test_if_branch_frame_axes_handles_long_passthrough_chain(force_compatibility_scheduler: bool) -> None: + nodes: list[Any] = [CollectionConcatInvocation(id="source", first=["x"]), IterateInvocation(id="iterate")] + edges: list[Any] = [("source", "collection", "iterate", "collection")] + previous = ("iterate", "item") + for index in range(1000): + node_id = f"pass{index}" + nodes.append(AnyTypeTestInvocation(id=node_id)) + edges.append((*previous, node_id, "value")) + previous = (node_id, "value") + nodes += [ + FixedCollectionTestInvocation(id="condition", value=True), + IfInvocation(id="if"), + CollectInvocation(id="collect"), + ] + edges += [ + ("iterate", "item", "condition", "value"), + ("condition", "condition", "if", "condition"), + (*previous, "if", "true_input"), + ("if", "value", "collect", "item"), + ] + state = GraphExecutionState(graph=_graph_from(nodes, edges)) + trace, state = _run_graph(state, force_compatibility_scheduler=force_compatibility_scheduler) + + assert not state.has_error(), state.errors + assert state.is_complete(), f"graph stalled after {trace}" + assert _source_values(state, "collect") == {(): ["x"]} + + @pytest.mark.parametrize("force_compatibility_scheduler", [False, True], ids=["default", "compatibility"]) def test_per_item_if_rejects_asymmetric_collect_chain_before_invocation( force_compatibility_scheduler: bool, @@ -4480,6 +4753,47 @@ def test_literal_if_ignores_independent_axis_reaching_it_through_unselected_for_ assert _source_values(state, "collect") == {(): expected} +@pytest.mark.parametrize("condition_source", ["literal", "computed"], ids=["literal", "computed"]) +@pytest.mark.parametrize("condition", [True, False], ids=["true", "false"]) +@pytest.mark.parametrize("unselected_values", [[], [["u", "v"]]], ids=["empty-unselected", "non-empty-unselected"]) +@pytest.mark.parametrize("force_compatibility_scheduler", [False, True], ids=["default", "compatibility"]) +def test_root_if_selection_ignores_empty_unselected_for_axis( + condition_source: str, condition: bool, unselected_values: list[Any], force_compatibility_scheduler: bool +) -> None: + root_branch, for_branch = ("true_input", "false_input") if condition else ("false_input", "true_input") + nodes: list[Any] = [ + CollectionConcatInvocation(id="source", first=unselected_values), + IterateInvocation(id="iterate"), + ForInvocation(id="for"), + ForReturnInvocation(id="return"), + AnyTypeTestInvocation(id="root", value="root"), + IfInvocation(id="if", **({"condition": condition} if condition_source == "literal" else {})), + CollectInvocation(id="collect"), + ] + edges: list[Any] = [ + ("source", "collection", "iterate", "collection"), + ("iterate", "item", "for", "collection"), + ("for", "item", "return", "output"), + create_loop_linkage("for", "return"), + ("root", "value", "if", root_branch), + ("for", "output_collection", "if", for_branch), + ("if", "value", "collect", "item"), + ] + if condition_source == "computed": + nodes.append(FixedCollectionTestInvocation(id="condition", value=condition)) + edges.append(("condition", "condition", "if", "condition")) + trace, state = _run_graph( + GraphExecutionState(graph=_graph_from(nodes, edges)), + force_compatibility_scheduler=force_compatibility_scheduler, + ) + + assert not state.has_error(), state.errors + assert state.is_complete(), f"graph stalled after {trace}" + assert _source_values(state, "collect") == {(): ["root"]} + assert trace.count("if") == 1 + assert not any(source_id in {"for", "return"} for source_id in trace) + + @pytest.mark.parametrize("condition", [True, False]) def test_root_condition_if_broadcasts_over_nested_axes_both_branches_share(condition: bool) -> None: """A root-frame condition selects between branches that run in the same nested frames, once per inner item.""" diff --git a/tests/app/services/shared/test_graph_if_runtime.py b/tests/app/services/shared/test_graph_if_runtime.py index c126bff521d..4e9e404cf93 100644 --- a/tests/app/services/shared/test_graph_if_runtime.py +++ b/tests/app/services/shared/test_graph_if_runtime.py @@ -2,6 +2,7 @@ import pytest +from invokeai.app.invocations.collections import CollectionConcatInvocation from invokeai.app.invocations.logic import BooleanLogicInvocation, IfInvocation from invokeai.app.invocations.loops import ForInvocation from invokeai.app.invocations.math import AddInvocation @@ -13,6 +14,7 @@ from invokeai.app.services.shared.graph_runtime_records import _ApplyTransaction from invokeai.app.services.shared.graph_validation import CollectInvocation, IterateInvocation from tests.app.services.shared.import_test_utils import assert_module_imports_without_graph +from tests.test_nodes import AnyTypeTestInvocation @pytest.fixture @@ -241,6 +243,86 @@ def test_resolved_literal_if_uses_root_frame_path() -> None: ) == [()] +def test_if_branch_frame_axes_deduplicates_stably_with_linear_comparisons(monkeypatch: pytest.MonkeyPatch) -> None: + comparisons = 0 + + class Axis(str): + def __eq__(self, other: object) -> bool: + nonlocal comparisons + comparisons += 1 + return super().__eq__(other) + + __hash__ = str.__hash__ + + axes = [Axis(f"axis-{index}") for index in range(1000)] + graph = Graph(nodes={"if": IfInvocation(id="if")}) + for index in range(2): + source_id = f"source{index}" + graph.add_node(AddInvocation(id=source_id)) + graph.add_edge( + Edge( + source=EdgeConnection(node_id=source_id, field="value"), + destination=EdgeConnection(node_id="if", field="true_input" if index == 0 else "false_input"), + ) + ) + state = SimpleNamespace(graph=graph) + axis_tuple = tuple(axes) + monkeypatch.setattr(graph_if_runtime, "_get_edge_frame_axes", lambda _state, _ancestry, edge, _cache: axis_tuple) + + result = graph_if_runtime._get_if_branch_frame_axes( + state, _IteratorAncestry(GraphExecutionState(graph=Graph())), "if", {} + ) + + assert tuple(result["true_input"]) == tuple(f"axis-{index}" for index in range(1000)) + assert result["true_input"] == result["false_input"] + assert comparisons < 10_000 + + +def test_edge_frame_axis_traversal_visits_long_passthrough_chain_linearly(monkeypatch: pytest.MonkeyPatch) -> None: + chain_length = 1000 + graph = Graph() + graph.nodes["source"] = CollectionConcatInvocation(id="source", first=["x"]) + graph.nodes["iterate"] = IterateInvocation(id="iterate") + graph.nodes["if"] = IfInvocation(id="if") + graph.add_edge( + Edge( + source=EdgeConnection(node_id="source", field="collection"), + destination=EdgeConnection(node_id="iterate", field="collection"), + ) + ) + previous = ("iterate", "item") + for index in range(chain_length): + node_id = f"pass{index}" + graph.nodes[node_id] = AnyTypeTestInvocation(id=node_id) + graph.add_edge( + Edge( + source=EdgeConnection(node_id=previous[0], field=previous[1]), + destination=EdgeConnection(node_id=node_id, field="value"), + ) + ) + previous = (node_id, "value") + edge = Edge( + source=EdgeConnection(node_id=previous[0], field=previous[1]), + destination=EdgeConnection(node_id="if", field="true_input"), + ) + graph.add_edge(edge) + state = GraphExecutionState(graph=graph) + get_input_edges = graph._get_input_edges + visits = 0 + + def counted_get_input_edges(node_id: str, *args: object, **kwargs: object) -> list[Edge]: + nonlocal visits + visits += 1 + return get_input_edges(node_id, *args, **kwargs) + + monkeypatch.setattr(graph, "_get_input_edges", counted_get_input_edges) + + axes = graph_if_runtime._get_edge_frame_axes(state, _IteratorAncestry(state), edge, {}) + + assert axes == ("iterate",) + assert visits < chain_length * 3 + + @pytest.mark.parametrize("executed", [False, True]) def test_prepared_selected_source_preserves_paths_without_ancestry(executed: bool) -> None: state = GraphExecutionState( From ed366dbd304eb2f50a4168f3dadd3e8e7595fa4a Mon Sep 17 00:00:00 2001 From: JPPhoto Date: Mon, 26 Oct 2026 09:00:46 -0500 Subject: [PATCH 5/7] perf(graph): reuse If frame-axis work Reuse branch destination membership and retained Collect axes during frame inference. Cover empty-path fallback with an edge-axis lookup regression. --- .../app/services/shared/graph_if_runtime.py | 26 +++++----- .../services/shared/test_graph_if_runtime.py | 51 +++++++++++++++++++ 2 files changed, 64 insertions(+), 13 deletions(-) diff --git a/invokeai/app/services/shared/graph_if_runtime.py b/invokeai/app/services/shared/graph_if_runtime.py index 1eaa1340666..937db61690e 100644 --- a/invokeai/app/services/shared/graph_if_runtime.py +++ b/invokeai/app/services/shared/graph_if_runtime.py @@ -213,11 +213,12 @@ def _validate_collect_branch_axes( for source_id in nx.ancestors(source_graph, edge.source.node_id) | {edge.source.node_id} } retained_axes = set() + branch_destinations = branch_nodes | {node_id} for collect_id in branch_nodes: if not isinstance(state.graph.get_node(collect_id), CollectInvocation): continue for edge in state.graph._get_output_edges(collect_id, COLLECTION_FIELD): - if edge.destination.node_id not in branch_nodes | {node_id} or ( + if edge.destination.node_id not in branch_destinations or ( edge.destination.node_id == node_id and edge.destination.field != branch_field ): continue @@ -409,6 +410,15 @@ def _get_effective_iteration_paths_for_edge( if isinstance(source_node, CollectInvocation) and current_edge.source.field == COLLECTION_FIELD: input_edges = state.graph._get_input_edges(current_source_id) collapsed_paths: set[tuple[int, ...]] = set() + frame_axes_cache: dict[str, Optional[tuple[str, ...]]] = {} + retained_axes_by_edge = { + input_edge: _without_closed_axis( + state, + input_edge, + _get_edge_frame_axes(state, iterator_ancestry, input_edge, frame_axes_cache) or (), + ) + for input_edge in input_edges + } # Collect.item collapses its final open axis even before its own execution frame exists. for input_edge in input_edges: input_paths = _get_effective_iteration_paths_for_edge( @@ -420,21 +430,11 @@ def _get_effective_iteration_paths_for_edge( iterator_ancestry, cache, ) - group_depth = len( - _without_closed_axis( - state, - input_edge, - _get_edge_frame_axes(state, iterator_ancestry, input_edge, {}) or (), - ) - ) + group_depth = len(retained_axes_by_edge[input_edge]) collapsed_paths.update(path[:group_depth] for path in input_paths) if not collapsed_paths and input_edges: for input_edge in input_edges: - group_axes = _without_closed_axis( - state, - input_edge, - _get_edge_frame_axes(state, iterator_ancestry, input_edge, {}) or (), - ) + group_axes = retained_axes_by_edge[input_edge] if not group_axes: collapsed_paths.add(()) continue diff --git a/tests/app/services/shared/test_graph_if_runtime.py b/tests/app/services/shared/test_graph_if_runtime.py index 4e9e404cf93..5b4e971ab08 100644 --- a/tests/app/services/shared/test_graph_if_runtime.py +++ b/tests/app/services/shared/test_graph_if_runtime.py @@ -346,3 +346,54 @@ def test_prepared_selected_source_preserves_paths_without_ancestry(executed: boo lambda *_: (2,), _IteratorAncestry(state), ) == ([] if executed else [(2,)]) + + +def test_collect_empty_path_fallback_reuses_input_edge_axes(monkeypatch: pytest.MonkeyPatch) -> None: + graph = Graph( + nodes={ + "outer": AddInvocation(id="outer"), + "source": AddInvocation(id="source"), + "collect": CollectInvocation(id="collect"), + } + ) + graph.edges.append( + Edge( + source=EdgeConnection(node_id="outer", field="item"), + destination=EdgeConnection(node_id="source", field="a"), + ) + ) + input_edge = Edge( + source=EdgeConnection(node_id="source", field="output_collection"), + destination=EdgeConnection(node_id="collect", field="item"), + ) + graph.edges.append(input_edge) + graph.edges.append( + Edge( + source=EdgeConnection(node_id="outer", field="item"), + destination=EdgeConnection(node_id="consumer", field="value"), + ) + ) + graph._invalidate_edge_indexes() + state = SimpleNamespace(graph=graph, source_prepared_mapping={"outer"}) + axis_lookups = 0 + + def get_edge_frame_axes(_state: object, _ancestry: object, edge: Edge, _cache: dict) -> tuple[str, ...]: + nonlocal axis_lookups + assert edge == input_edge + axis_lookups += 1 + return ("outer", "inner") + + monkeypatch.setattr(graph_if_runtime, "_get_edge_frame_axes", get_edge_frame_axes) + + assert graph_if_runtime._get_effective_iteration_paths_for_edge( + state, + Edge( + source=EdgeConnection(node_id="collect", field="collection"), + destination=EdgeConnection(node_id="consumer", field="value"), + ), + {"source"}, + lambda _source_id: {"outer-exec"}, + lambda _edge, _prepared_id: (), + _IteratorAncestry(GraphExecutionState(graph=Graph())), + ) == {()} + assert axis_lookups == 1 From d630a335278bd8c736b57fb5d0f21ecade8ac800 Mon Sep 17 00:00:00 2001 From: JPPhoto Date: Tue, 27 Oct 2026 07:06:15 -0500 Subject: [PATCH 6/7] fix(graph): resolve If frame stalls Handle selected For-final frames consistently and skip unselected literal branches during materialization. Cache static If frame-axis analysis and cover review regressions. --- invokeai/app/services/shared/README.md | 3 +- .../app/services/shared/graph_if_runtime.py | 113 +++++-- .../shared/graph_iterator_ancestry.py | 2 +- .../app/services/shared/graph_materializer.py | 43 ++- .../test_execution_engine_differential.py | 278 +++++++++++++++++- .../services/shared/test_graph_if_runtime.py | 16 + 6 files changed, 415 insertions(+), 40 deletions(-) diff --git a/invokeai/app/services/shared/README.md b/invokeai/app/services/shared/README.md index a9aa4b10aa5..daac252e2ae 100644 --- a/invokeai/app/services/shared/README.md +++ b/invokeai/app/services/shared/README.md @@ -856,7 +856,8 @@ Run `C` -> `D:0` -> enqueue `D`. Run `D` -> done. body runs. A gated `For` brings in a `For` nested in its body together with that body. Two `For`s stay ungated. A `For` nested in the body of an ungated `For` runs with that loop. A `For` whose final output feeds the `If` directly attaches after it finalizes; with a per-item condition, rejected frames do not run the `For` or its body, and selected - frames wait for its final output before the `If` input is attached. + frames wait for its final output before the `If` input is attached. A literal `If` waits only for its selected branch; + an unselected `For` final input does not delay it. - An `If` has no execution in a frame where the innermost iterator in its ancestry has no items. When an `Iterate` in its iterator ancestry, whether it feeds `If.condition` or a branch, has no items at all, no `If` execution is created, the branch sources are retired empty, the downstream `Collect` produces an empty collection, and the state completes diff --git a/invokeai/app/services/shared/graph_if_runtime.py b/invokeai/app/services/shared/graph_if_runtime.py index 937db61690e..ef876cce09b 100644 --- a/invokeai/app/services/shared/graph_if_runtime.py +++ b/invokeai/app/services/shared/graph_if_runtime.py @@ -341,6 +341,44 @@ def _get_if_branch_frame_axes( return result +def _get_if_static_frame_analysis( + state: "GraphExecutionState", iterator_ancestry: _IteratorAncestry, node_id: str +) -> tuple[dict[str, tuple[str, ...]], dict[str, set[str]]]: + """Cache branch axes and the axes reached through For final outputs for this graph traversal.""" + cache = getattr(iterator_ancestry, "_if_static_frame_analysis", None) + if cache is None: + cache = {} + iterator_ancestry._if_static_frame_analysis = cache + if node_id in cache: + return cache[node_id] + + branch_axes = _get_if_branch_frame_axes(state, iterator_ancestry, node_id, {}) + final_axes: dict[str, set[str]] = {} + for branch_field in ("true_input", "false_input"): + branch_final_axes: set[str] = set() + pending = list(state.graph._get_input_edges(node_id, branch_field)) + visited: set[str] = set() + while pending: + edge = pending.pop() + source_id = edge.source.node_id + if source_id in visited: + continue + visited.add(source_id) + source_node = state.graph.get_node(source_id) + if ( + isinstance(source_node, ForInvocation) + and get_output_field_scope(source_node, edge.source.field) == OutputScope.Final + ): + branch_final_axes.update(iterator_ancestry.get_frame_axes_for_edge(edge)) + else: + pending.extend(state.graph._get_input_edges(source_id)) + final_axes[branch_field] = branch_final_axes + + analysis = (branch_axes, final_axes) + cache[node_id] = analysis + return analysis + + def _get_effective_iteration_paths_for_edge( state: "GraphExecutionState", edge: Edge, @@ -348,7 +386,7 @@ def _get_effective_iteration_paths_for_edge( get_prepared_nodes_for_source: Callable[[str], set[str]], get_prepared_edge_iteration_path: Callable[[Edge, str], tuple[int, ...]], iterator_ancestry: _IteratorAncestry, - effective_paths_by_edge: Optional[dict[tuple[str, str], set[tuple[int, ...]]]] = None, + effective_paths_by_edge: Optional[dict[Edge, set[tuple[int, ...]]]] = None, ) -> set[tuple[int, ...]]: cache = effective_paths_by_edge if effective_paths_by_edge is not None else {} pending: list[tuple[Edge, bool]] = [(edge, False)] @@ -356,7 +394,7 @@ def _get_effective_iteration_paths_for_edge( while pending: current_edge, exiting_source = pending.pop() current_source_id = current_edge.source.node_id - cache_key = (current_source_id, current_edge.source.field) + cache_key = current_edge if cache_key in cache: continue @@ -365,9 +403,7 @@ def _get_effective_iteration_paths_for_edge( if not input_edges: cache[cache_key] = {()} else: - cache[cache_key] = set().union( - *(cache[(input_edge.source.node_id, input_edge.source.field)] for input_edge in input_edges) - ) + cache[cache_key] = set().union(*(cache[input_edge] for input_edge in input_edges)) continue if current_source_id in visited_source_ids: @@ -488,7 +524,7 @@ def _get_effective_iteration_paths_for_edge( pending.append((current_edge, True)) pending.extend((input_edge, False) for input_edge in reversed(input_edges)) - return cache[(edge.source.node_id, edge.source.field)] + return cache[edge] def _get_if_iteration_paths_with_ancestry( @@ -512,9 +548,8 @@ def _get_if_iteration_paths_with_ancestry( except nx.NetworkXError: # A filtered materialization view can omit unresolved condition sources. continue - branch_axes = { - branch: set(axes) for branch, axes in _get_if_branch_frame_axes(state, iterator_ancestry, node_id, {}).items() - } + static_branch_axes, branch_for_final_axes = _get_if_static_frame_analysis(state, iterator_ancestry, node_id) + branch_axes = {branch: set(axes) for branch, axes in static_branch_axes.items()} shared_deeper_branch_axes = ( bool(branch_axes["true_input"]) and branch_axes["true_input"] == branch_axes["false_input"] @@ -538,9 +573,13 @@ def _get_if_iteration_paths_with_ancestry( condition_selections.append((None, selected_field)) paths: set[tuple[int, ...]] = set() + unselected_final_axes: set[str] = set() has_selected_edges = False has_unresolved_selected_source = False for condition_path, selected_field in condition_selections: + if condition_path in (None, ()): + other_field = "false_input" if selected_field == "true_input" else "true_input" + unselected_final_axes.update(branch_for_final_axes[other_field] - branch_for_final_axes[selected_field]) selected_edges = state.graph._get_input_edges(node_id, selected_field) has_selected_edges = has_selected_edges or bool(selected_edges) has_unresolved_selected_source = has_unresolved_selected_source or any( @@ -624,7 +663,11 @@ def _get_if_iteration_paths_with_ancestry( # The If runs once per frame of the innermost iterator open at it; validation keeps those iterators nested. An # iterator that has not run leaves the frames unknown, and one with no items leaves the If no frames. An iterator # reaching the If only through a For's final output does not add frames here. - frame_iterators = iterator_ancestry.get_ordered_iterator_ancestors(node_id) + frame_iterators = [ + axis_id + for axis_id in iterator_ancestry.get_ordered_iterator_ancestors(node_id) + if axis_id not in unselected_final_axes + ] if frame_iterators: innermost_id = frame_iterators[-1] if innermost_id not in state.executed and innermost_id not in state.source_prepared_mapping: @@ -819,7 +862,7 @@ def _has_empty_if_frame(state: "GraphExecutionState", if_node_id: str) -> bool: ancestry = state._runtime()._iterator_ancestry condition_edges = state.graph._get_input_edges(if_node_id, "condition") axes = {axis_id for edge in condition_edges for axis_id in ancestry.get_frame_axes_for_edge(edge)} - branch_axes = _get_if_branch_frame_axes(state, ancestry, if_node_id, {}) + branch_axes, branch_final_axes = _get_if_static_frame_analysis(state, ancestry, if_node_id) selected_fields: set[str] = set() if not condition_edges: @@ -832,23 +875,7 @@ def _has_empty_if_frame(state: "GraphExecutionState", if_node_id: str) -> bool: selected_fields.add("true_input" if value else "false_input") for branch_field, branch_axis_ids in branch_axes.items(): - branch_for_final_axes: set[str] = set() - pending = list(state.graph._get_input_edges(if_node_id, branch_field)) - visited: set[str] = set() - while pending: - edge = pending.pop() - source_id = edge.source.node_id - if source_id in visited: - continue - visited.add(source_id) - source_node = state.graph.get_node(source_id) - if ( - isinstance(source_node, ForInvocation) - and get_output_field_scope(source_node, edge.source.field) == OutputScope.Final - ): - branch_for_final_axes.update(ancestry.get_frame_axes_for_edge(edge)) - continue - pending.extend(state.graph._get_input_edges(source_id)) + branch_for_final_axes = branch_final_axes[branch_field] if branch_field in selected_fields: axes.update(branch_axis_ids) else: @@ -862,6 +889,36 @@ def _has_empty_if_frame(state: "GraphExecutionState", if_node_id: str) -> bool: ) +def _has_empty_selected_for_final_frame( + state: "GraphExecutionState", iterator_ancestry: _IteratorAncestry, if_node_id: str +) -> bool: + """Check for an empty root-selected branch whose open axes all come from For final outputs.""" + condition_edges = state.graph._get_input_edges(if_node_id, "condition") + if not condition_edges or any(iterator_ancestry.get_frame_axes_for_edge(edge) for edge in condition_edges): + return False + branch_axes, branch_final_axes = _get_if_static_frame_analysis(state, iterator_ancestry, if_node_id) + condition_edge = condition_edges[0] + for exec_id in state.source_prepared_mapping.get(condition_edge.source.node_id, ()): + if exec_id not in state.results: + continue + value = getattr(state.results[exec_id], condition_edge.source.field) + selected_field = "true_input" if value else "false_input" + selected_axes = set(branch_axes[selected_field]) + final_axes = branch_final_axes[selected_field] + if not selected_axes or not selected_axes <= final_axes: + continue + if not set(iterator_ancestry.get_ordered_iterator_ancestors(if_node_id)) <= final_axes: + continue + if any( + isinstance(state.graph.get_node(axis_id), IterateInvocation) + and axis_id in state.executed + and not state.source_prepared_mapping.get(axis_id) + for axis_id in final_axes + ): + return True + return False + + def _has_unresolved_if_frame_axes( state: "GraphExecutionState", if_node_id: str, iterator_ancestry: _IteratorAncestry ) -> bool: diff --git a/invokeai/app/services/shared/graph_iterator_ancestry.py b/invokeai/app/services/shared/graph_iterator_ancestry.py index e2ab99d555c..4bce3410a8e 100644 --- a/invokeai/app/services/shared/graph_iterator_ancestry.py +++ b/invokeai/app/services/shared/graph_iterator_ancestry.py @@ -36,7 +36,7 @@ def __init__( self._has_iterator_ancestor: dict[str, bool] | None = None self._ordered_iterator_ancestors: dict[str, tuple[str, ...]] = {} self._frame_iterators: dict[str, tuple[str, ...]] = {} - self.effective_iteration_paths_by_edge: dict[tuple[str, str], set[tuple[int, ...]]] = {} + self.effective_iteration_paths_by_edge: dict[Edge, set[tuple[int, ...]]] = {} def clear_effective_iteration_paths(self) -> None: """Invalidate prepared-path results when execution materialization advances.""" diff --git a/invokeai/app/services/shared/graph_materializer.py b/invokeai/app/services/shared/graph_materializer.py index 501e603a076..850ce410d18 100644 --- a/invokeai/app/services/shared/graph_materializer.py +++ b/invokeai/app/services/shared/graph_materializer.py @@ -341,7 +341,12 @@ def _get_activation_candidate_source_ids(self, graph: "nx.DiGraph") -> tuple[str def _has_unmaterializable_for_final_input(self, node_id: str) -> bool: final_for_source_ids = set() - for edge in self._state.graph._get_input_edges(node_id): + node = self._state.graph.get_node(node_id) + input_edges = self._state.graph._get_input_edges(node_id) + if isinstance(node, IfInvocation) and not self._state.graph._get_input_edges(node_id, "condition"): + selected_field = "true_input" if node.condition else "false_input" + input_edges = self._state.graph._get_input_edges(node_id, selected_field) + for edge in input_edges: source_node = self._state.graph.get_node(edge.source.node_id) if not isinstance(source_node, ForInvocation): continue @@ -1764,7 +1769,35 @@ def _get_if_condition_iteration_mappings( if condition_iterator_ids: iterator_nodes = condition_axes else: - iterator_nodes = list(self._get_iterator_ancestry(iterator_graph).get_frame_iterators(node_id)) + # A root condition's frames come from its resolved selected branch. Mapping every open axis here + # duplicates root results when only the unselected branch carries an axis. + condition_mappings = self._get_parent_iteration_mappings( + node_id, graph, input_edges=condition_edges, iterator_node_ids=[] + ) + branch_edges = { + branch: self._state.graph._get_input_edges(node_id, branch) + for branch in ("true_input", "false_input") + } + + def get_root_condition_mappings() -> Iterable[list[tuple[str, str]]]: + for mapping in condition_mappings: + condition_exec_id = dict(mapping)[condition_edges[0].source.node_id] + if condition_exec_id not in self._state.results: + continue + condition_value = getattr( + self._state.results[condition_exec_id], condition_edges[0].source.field + ) + selected_field = "true_input" if condition_value else "false_input" + axes = self._get_ordered_iterator_axes_for_edges(branch_edges[selected_field], iterator_graph) + if not axes: + yield mapping + continue + axis_id = axes[-1] + prepared_axis_ids = self._get_prepared_nodes_for_source(axis_id) + for prepared_axis_id in sorted(prepared_axis_ids, key=self._state._get_iteration_path): + yield [*mapping, (axis_id, prepared_axis_id)] + + return get_root_condition_mappings() else: iterator_nodes = self.get_node_iterators(node_id, iterator_graph) if condition_edges and not iterator_nodes: @@ -2583,7 +2616,11 @@ def prepare(self, base_g: Optional["nx.DiGraph"] = None) -> Optional[str]: input_fields={"condition"}, ) new_node_ids.extend(create_results) - elif not self.get_node_iterators(next_node_id): + elif not self.get_node_iterators( + next_node_id + ) and not graph_if_runtime._has_empty_selected_for_final_frame( + self._state, iterator_ancestry, next_node_id + ): # Root-frame If nodes may be prepared from their condition while branch sources # are still being materialized; branch inputs attach after the gate resolves. for iteration_mapping in iteration_mappings: diff --git a/tests/app/services/shared/test_execution_engine_differential.py b/tests/app/services/shared/test_execution_engine_differential.py index 1561d444471..eac126e8aa1 100644 --- a/tests/app/services/shared/test_execution_engine_differential.py +++ b/tests/app/services/shared/test_execution_engine_differential.py @@ -3560,16 +3560,21 @@ def test_if_in_for_body_rejecting_nested_for_branch_completes(computed_condition @pytest.mark.parametrize( - ("condition", "computed_condition"), - [(True, True), (False, False), (False, True)], - ids=["computed-True", "literal-False", "computed-False"], + ("condition", "computed_condition", "source_data"), + [ + (True, True, [["a", "b"], ["c"]]), + (False, False, [["a", "b"], ["c"]]), + (False, True, [["a", "b"], ["c"]]), + (False, True, []), + ], + ids=["computed-True", "literal-False", "computed-False", "computed-False-empty-source"], ) def test_root_condition_if_with_for_output_branch_under_iterate_completes( - condition: bool, computed_condition: bool + condition: bool, computed_condition: bool, source_data: list[list[str]] ) -> None: """The Iterate a branch For runs under is not gated by the If, and the rejected For never runs.""" nodes = [ - CollectionConcatInvocation(id="src", first=[["a", "b"], ["c"]]), + CollectionConcatInvocation(id="src", first=source_data), IterateInvocation(id="iterate"), ForInvocation(id="for"), MarkedAnyTypeTestInvocation(id="body", marker="b:"), @@ -3596,13 +3601,272 @@ def test_root_condition_if_with_for_output_branch_under_iterate_completes( trace, state = _run_to_completion(_graph_from(nodes, edges)) - # Selecting the root value keeps main's single root-frame If; selected-branch cardinality is decided separately. - expected = [["b:a", "b:b"], ["b:c"]] if condition else (["fb", "fb"] if computed_condition else ["fb"]) + expected = [["b:a", "b:b"], ["b:c"]] if condition else ["fb"] assert _source_values(state, "collect") == {(): expected} if not condition: assert not {"for", "body", "ret", "mid"} & set(trace) +@pytest.mark.parametrize("force_compatibility_scheduler", [False, True], ids=["generic", "compatibility"]) +def test_computed_root_if_selecting_root_value_over_unselected_for_emits_one_result( + force_compatibility_scheduler: bool, +) -> None: + graph = _graph_from( + [ + CollectionConcatInvocation(id="src", first=[["c"], ["d"], ["e"]]), + IterateInvocation(id="iterate"), + ForInvocation(id="for"), + MarkedAnyTypeTestInvocation(id="body", marker="F"), + ForReturnInvocation(id="ret"), + AnyTypeTestInvocation(id="root", value="R"), + BooleanInvocation(id="condition", value=True), + IfInvocation(id="if"), + CollectInvocation(id="collect"), + ], + [ + ("src", "collection", "iterate", "collection"), + ("iterate", "item", "for", "collection"), + ("for", "item", "body", "value"), + ("body", "value", "ret", "output"), + create_loop_linkage("for", "ret"), + ("root", "value", "if", "true_input"), + ("for", "output_collection", "if", "false_input"), + ("condition", "value", "if", "condition"), + ("if", "value", "collect", "item"), + ], + ) + + trace, state = _run_graph( + GraphExecutionState(graph=graph), force_compatibility_scheduler=force_compatibility_scheduler + ) + + assert state.is_complete(), f"graph stalled after {trace}" + assert _source_values(state, "collect") == {(): ["R"]} + assert trace.count("if") == 1 + assert not {"for", "body", "ret"} & set(trace) + + +@pytest.mark.parametrize("force_compatibility_scheduler", [False, True], ids=["generic", "compatibility"]) +def test_computed_root_if_selecting_root_value_over_empty_unselected_for_emits_one_result( + force_compatibility_scheduler: bool, +) -> None: + graph = _graph_from( + [ + CollectionConcatInvocation(id="src", first=[]), + IterateInvocation(id="iterate"), + ForInvocation(id="for"), + MarkedAnyTypeTestInvocation(id="body", marker="F"), + ForReturnInvocation(id="ret"), + AnyTypeTestInvocation(id="root", value="R"), + BooleanInvocation(id="condition", value=True), + IfInvocation(id="if"), + CollectInvocation(id="collect"), + ], + [ + ("src", "collection", "iterate", "collection"), + ("iterate", "item", "for", "collection"), + ("for", "item", "body", "value"), + ("body", "value", "ret", "output"), + create_loop_linkage("for", "ret"), + ("root", "value", "if", "true_input"), + ("for", "output_collection", "if", "false_input"), + ("condition", "value", "if", "condition"), + ("if", "value", "collect", "item"), + ], + ) + + trace, state = _run_graph( + GraphExecutionState(graph=graph), force_compatibility_scheduler=force_compatibility_scheduler + ) + + assert state.is_complete(), f"graph stalled after {trace}" + assert _source_values(state, "collect") == {(): ["R"]} + assert trace.count("if") == 1 + assert not {"for", "body", "ret"} & set(trace) + + +@pytest.mark.parametrize("force_compatibility_scheduler", [False, True], ids=["generic", "compatibility"]) +@pytest.mark.parametrize("for_field", ["true_input", "false_input"], ids=["true-for", "false-for"]) +def test_literal_if_skips_direct_unselected_for_final(force_compatibility_scheduler: bool, for_field: str) -> None: + selected_field = "false_input" if for_field == "true_input" else "true_input" + graph = _graph_from( + [ + CollectionConcatInvocation(id="src", first=[["a"], ["b"]]), + IterateInvocation(id="iterate"), + ForInvocation(id="for"), + MarkedAnyTypeTestInvocation(id="body", marker="F"), + ForReturnInvocation(id="ret"), + AnyTypeTestInvocation(id="fallback", value="fb"), + IfInvocation(id="if", condition=selected_field == "true_input"), + CollectInvocation(id="collect"), + ], + [ + ("src", "collection", "iterate", "collection"), + ("iterate", "item", "for", "collection"), + ("for", "item", "body", "value"), + ("body", "value", "ret", "output"), + create_loop_linkage("for", "ret"), + ("for", "output_collection", "if", for_field), + ("fallback", "value", "if", selected_field), + ("if", "value", "collect", "item"), + ], + ) + + trace, state = _run_graph( + GraphExecutionState(graph=graph), force_compatibility_scheduler=force_compatibility_scheduler + ) + + assert state.is_complete(), f"graph stalled after {trace}" + assert _source_values(state, "collect") == {(): ["fb"]} + assert not {"for", "body", "ret"} & set(trace) + + +@pytest.mark.parametrize("force_compatibility_scheduler", [False, True], ids=["generic", "compatibility"]) +def test_nested_literal_if_under_wired_root_if_keeps_parent_iterate_frames( + force_compatibility_scheduler: bool, +) -> None: + graph = _graph_from( + [ + CollectionConcatInvocation(id="src", first=[["a", "c"], ["c", "b"]]), + IterateInvocation(id="iterate"), + ForInvocation(id="left_for"), + MarkedAnyTypeTestInvocation(id="left_body", marker="F"), + ForReturnInvocation(id="left_ret"), + ForInvocation(id="right_for"), + MarkedAnyTypeTestInvocation(id="right_body", marker="F"), + ForReturnInvocation(id="right_ret"), + IfInvocation(id="inner_if", condition=True), + MarkedAnyTypeTestInvocation(id="passthrough", marker="p:"), + AnyTypeTestInvocation(id="root", value="R"), + BooleanInvocation(id="condition", value=True), + IfInvocation(id="outer_if"), + CollectInvocation(id="collect"), + ], + [ + ("src", "collection", "iterate", "collection"), + ("iterate", "item", "left_for", "collection"), + ("left_for", "item", "left_body", "value"), + ("left_body", "value", "left_ret", "output"), + create_loop_linkage("left_for", "left_ret"), + ("iterate", "item", "right_for", "collection"), + ("right_for", "item", "right_body", "value"), + ("right_body", "value", "right_ret", "output"), + create_loop_linkage("right_for", "right_ret"), + ("left_for", "output_collection", "inner_if", "true_input"), + ("right_for", "output_collection", "inner_if", "false_input"), + ("inner_if", "value", "passthrough", "value"), + ("passthrough", "value", "outer_if", "true_input"), + ("root", "value", "outer_if", "false_input"), + ("condition", "value", "outer_if", "condition"), + ("outer_if", "value", "collect", "item"), + ], + ) + + trace, state = _run_graph( + GraphExecutionState(graph=graph), force_compatibility_scheduler=force_compatibility_scheduler + ) + + assert state.is_complete(), f"graph stalled after {trace}" + assert _source_values(state, "collect") == {(): ["p:['Fa', 'Fc']", "p:['Fc', 'Fb']"]} + assert trace.count("inner_if") == 2 + assert trace.count("outer_if") == 2 + + +@pytest.mark.parametrize("force_compatibility_scheduler", [False, True], ids=["generic", "compatibility"]) +def test_literal_if_after_final_iterate_checkpoint_completes_without_unselected_for( + force_compatibility_scheduler: bool, +) -> None: + graph = _graph_from( + [ + CollectionConcatInvocation(id="src", first=[["a", "b"], ["c"], ["d"]]), + IterateInvocation(id="iterate"), + ForInvocation(id="for"), + MarkedAnyTypeTestInvocation(id="body", marker="F"), + ForReturnInvocation(id="ret"), + AnyTypeTestInvocation(id="root", value="R"), + IfInvocation(id="if", condition=False), + CollectInvocation(id="collect"), + ], + [ + ("src", "collection", "iterate", "collection"), + ("iterate", "item", "for", "collection"), + ("for", "item", "body", "value"), + ("body", "value", "ret", "output"), + create_loop_linkage("for", "ret"), + ("for", "output_collection", "if", "true_input"), + ("root", "value", "if", "false_input"), + ("if", "value", "collect", "item"), + ], + ) + trace, partial = _run_graph( + GraphExecutionState(graph=graph), force_compatibility_scheduler=force_compatibility_scheduler, stop_after=5 + ) + assert trace[-1] == "iterate" + + resumed_trace, state = _run_graph( + load_execution_state(dump_execution_state(partial)), + force_compatibility_scheduler=force_compatibility_scheduler, + ) + + assert state.is_complete(), f"graph stalled after {trace + resumed_trace}" + assert _source_values(state, "collect") == {(): ["R"]} + assert not {"for", "body", "ret"} & set(trace + resumed_trace) + + +@pytest.mark.parametrize("force_compatibility_scheduler", [False, True], ids=["generic", "compatibility"]) +def test_empty_nested_iterates_and_collects_complete_through_unselected_for( + force_compatibility_scheduler: bool, +) -> None: + graph = _graph_from( + [ + CollectionConcatInvocation(id="src", first=[[], [[[]], [["c", "a", "c"]]]]), + IterateInvocation(id="it0"), + CollectionConcatInvocation(id="concat1"), + IterateInvocation(id="it1"), + CollectionConcatInvocation(id="concat2"), + IterateInvocation(id="it2"), + MarkedAnyTypeTestInvocation(id="x", marker="x"), + CollectInvocation(id="branch_collect"), + MarkedAnyTypeTestInvocation(id="passthrough", marker="F:"), + CollectionConcatInvocation(id="empty_src", first=[]), + IterateInvocation(id="empty_iterate"), + ForInvocation(id="for"), + AnyTypeTestInvocation(id="for_body"), + ForReturnInvocation(id="for_ret"), + BooleanInvocation(id="condition", value=False), + IfInvocation(id="if"), + CollectInvocation(id="collect"), + ], + [ + ("src", "collection", "it0", "collection"), + ("it0", "item", "concat1", "first"), + ("concat1", "collection", "it1", "collection"), + ("it1", "item", "concat2", "first"), + ("concat2", "collection", "it2", "collection"), + ("it1", "item", "x", "value"), + ("x", "value", "branch_collect", "item"), + ("branch_collect", "collection", "passthrough", "value"), + ("empty_src", "collection", "empty_iterate", "collection"), + ("empty_iterate", "item", "for", "collection"), + ("for", "item", "for_body", "value"), + ("for_body", "value", "for_ret", "output"), + create_loop_linkage("for", "for_ret"), + ("for", "output_collection", "if", "true_input"), + ("passthrough", "value", "if", "false_input"), + ("condition", "value", "if", "condition"), + ("if", "value", "collect", "item"), + ], + ) + + trace, state = _run_graph( + GraphExecutionState(graph=graph), force_compatibility_scheduler=force_compatibility_scheduler + ) + + assert state.is_complete(), f"graph stalled after {trace}" + assert _source_values(state, "collect") == {(): ["F:[]", "F:['x[[]]', \"x[['c', 'a', 'c']]\"]"]} + assert "for" not in trace and "for_body" not in trace + + def test_literal_root_condition_ignores_unselected_for_output_branch_under_iterate() -> None: graph = _graph_from( [ diff --git a/tests/app/services/shared/test_graph_if_runtime.py b/tests/app/services/shared/test_graph_if_runtime.py index 5b4e971ab08..9b2965c70a6 100644 --- a/tests/app/services/shared/test_graph_if_runtime.py +++ b/tests/app/services/shared/test_graph_if_runtime.py @@ -31,6 +31,22 @@ def test_runtime_import_without_graph() -> None: assert_module_imports_without_graph("invokeai.app.services.shared.graph_if_runtime") +def test_empty_if_frame_analysis_reuses_graph_topology(monkeypatch: pytest.MonkeyPatch) -> None: + state = GraphExecutionState(graph=Graph(nodes={"if": IfInvocation(id="if")})) + original = graph_if_runtime._get_if_branch_frame_axes + analysis_count = 0 + + def count_analysis(*args: object, **kwargs: object) -> dict[str, tuple[str, ...]]: + nonlocal analysis_count + analysis_count += 1 + return original(*args, **kwargs) # type: ignore[arg-type] + + monkeypatch.setattr(graph_if_runtime, "_get_if_branch_frame_axes", count_analysis) + + assert [graph_if_runtime._has_empty_if_frame(state, "if") for _ in range(20)] == [False] * 20 + assert analysis_count == 1 + + @pytest.mark.parametrize( ("source_kind", "source_field", "axes", "expected"), [ From fd9277b8e5609eb459e98b3f9a26eb3fecc6fc6f Mon Sep 17 00:00:00 2001 From: JPPhoto Date: Tue, 27 Oct 2026 11:03:26 -0500 Subject: [PATCH 7/7] fix(graph): handle nested If collection frames Resolve empty nested If/For branches without stalling and preserve frame-matched Collect inputs. Add regressions for empty branches, sibling fan-in, sparse frames, and edge-order stability. --- .../app/services/shared/graph_if_runtime.py | 56 ++- .../app/services/shared/graph_materializer.py | 215 ++++++++-- .../test_execution_engine_differential.py | 382 +++++++++++++++++- .../services/shared/test_graph_if_runtime.py | 156 ++++++- 4 files changed, 757 insertions(+), 52 deletions(-) diff --git a/invokeai/app/services/shared/graph_if_runtime.py b/invokeai/app/services/shared/graph_if_runtime.py index ef876cce09b..05450dbd5ed 100644 --- a/invokeai/app/services/shared/graph_if_runtime.py +++ b/invokeai/app/services/shared/graph_if_runtime.py @@ -268,11 +268,50 @@ def _get_edge_frame_axes( outer axes. A result that is not one nested chain has no frame order to pair with and yields None. Results are cached for one validation run; Collect chains are resolved with an explicit stack. """ - source_node = state.graph.get_node(edge.source.node_id) - if isinstance(source_node, (IfInvocation, ForInvocation, IterateInvocation)): + source_id = edge.source.node_id + if source_id in frame_axes_cache: + return frame_axes_cache[source_id] + source_node = state.graph.get_node(source_id) + if isinstance(source_node, IfInvocation): + # Seed shared If sources before descending both branches; nested branch fan-in otherwise repeats the chain. + frame_axes_cache[source_id] = () + condition_edges = state.graph._get_input_edges(source_node.id, "condition") + branch_axes = _get_if_branch_frame_axes(state, iterator_ancestry, source_node.id, frame_axes_cache) + if not condition_edges and not any(branch_axes.values()): + axes = iterator_ancestry.get_frame_axes_for_edge(edge) + frame_axes_cache[source_id] = axes + return axes + if not condition_edges: + axes = set(branch_axes["true_input" if source_node.condition else "false_input"]) + else: + condition_axes: set[str] = set() + for condition_edge in condition_edges: + try: + condition_axes.update(iterator_ancestry.get_frame_axes_for_edge(condition_edge)) + except nx.NetworkXError: + # A filtered preparation view can omit unresolved condition sources. + continue + if condition_axes: + axes = condition_axes | set(branch_axes["true_input"]) | set(branch_axes["false_input"]) + else: + # A root condition resolves the nested If's branch before its output is consumed. Shared axes + # broadcast; an axis present only on one branch must not create a wait cycle for the other branch. + selected_fields = { + state._resolved_if_exec_branches.get(prepared_id) + for prepared_id in state.source_prepared_mapping.get(source_node.id, ()) + } + selected_fields.discard(None) + if selected_fields: + axes = set().union(*(branch_axes[field] for field in selected_fields)) + else: + axes = set(branch_axes["true_input"]).intersection(branch_axes["false_input"]) + ordered = tuple( + sorted(axes, key=lambda axis_id: (len(iterator_ancestry.get_frame_iterators(axis_id)), axis_id)) + ) + frame_axes_cache[source_id] = ordered + return ordered + if isinstance(source_node, (ForInvocation, IterateInvocation)): return iterator_ancestry.get_frame_axes_for_edge(edge) - if edge.source.node_id in frame_axes_cache: - return frame_axes_cache[edge.source.node_id] pending: list[tuple[Edge, bool]] = [(edge, False)] while pending: current_edge, exiting = pending.pop() @@ -344,13 +383,13 @@ def _get_if_branch_frame_axes( def _get_if_static_frame_analysis( state: "GraphExecutionState", iterator_ancestry: _IteratorAncestry, node_id: str ) -> tuple[dict[str, tuple[str, ...]], dict[str, set[str]]]: - """Cache branch axes and the axes reached through For final outputs for this graph traversal.""" + """Recompute dynamic branch axes and cache only topology-static For-final axes.""" cache = getattr(iterator_ancestry, "_if_static_frame_analysis", None) if cache is None: cache = {} iterator_ancestry._if_static_frame_analysis = cache if node_id in cache: - return cache[node_id] + return _get_if_branch_frame_axes(state, iterator_ancestry, node_id, {}), cache[node_id] branch_axes = _get_if_branch_frame_axes(state, iterator_ancestry, node_id, {}) final_axes: dict[str, set[str]] = {} @@ -374,9 +413,8 @@ def _get_if_static_frame_analysis( pending.extend(state.graph._get_input_edges(source_id)) final_axes[branch_field] = branch_final_axes - analysis = (branch_axes, final_axes) - cache[node_id] = analysis - return analysis + cache[node_id] = final_axes + return branch_axes, final_axes def _get_effective_iteration_paths_for_edge( diff --git a/invokeai/app/services/shared/graph_materializer.py b/invokeai/app/services/shared/graph_materializer.py index 850ce410d18..fcd939a7d05 100644 --- a/invokeai/app/services/shared/graph_materializer.py +++ b/invokeai/app/services/shared/graph_materializer.py @@ -1243,6 +1243,16 @@ def _get_collect_iteration_group_key(self, edge: Edge, sibling_depth: Optional[i ): return self._state._get_for_parent_iteration_path(edge.source.node_id) if edge.destination.field == ITEM_FIELD: + if ( + isinstance(source_node, IfInvocation) + and not self._state.graph._get_input_edges(source_node.id, "condition") + and any( + isinstance(self._state.graph.get_node(branch_edge.source.node_id), IfInvocation) + for branch in ("true_input", "false_input") + for branch_edge in self._state.graph._get_input_edges(source_node.id, branch) + ) + ): + return path if isinstance(source_node, ForInvocation) and source_node.index == -1: return path # Ragged siblings need the deepest path to identify their shared outer group. @@ -1342,7 +1352,18 @@ def _get_collect_candidate_group_keys(self, edge: Edge) -> set[tuple[int, ...]]: group_depth = len(iterator_node_ids) if edge.destination.field == ITEM_FIELD: - group_depth = max(group_depth - 1, 0) + source_node = self._state.graph.get_node(source_node_id) + nested_literal_if = ( + isinstance(source_node, IfInvocation) + and not self._state.graph._get_input_edges(source_node.id, "condition") + and any( + isinstance(self._state.graph.get_node(branch_edge.source.node_id), IfInvocation) + for branch in ("true_input", "false_input") + for branch_edge in self._state.graph._get_input_edges(source_node.id, branch) + ) + ) + if not nested_literal_if: + group_depth = max(group_depth - 1, 0) group_keys: set[tuple[int, ...]] = set() # A Collect.item edge groups by the outer `group_depth` axes. Read input paths from the current item axis @@ -1490,6 +1511,19 @@ def _mark_source_node_empty(self, source_node_id: str, iteration_path: tuple[int if isinstance(self._state.graph.get_node(source_node_id), IterateInvocation): self._state._record_empty_iterate_stream(source_node_id, iteration_path) + def _has_completed_empty_nested_if_branch(self, node_id: str) -> bool: + node = self._state.graph.get_node(node_id) + if not isinstance(node, IfInvocation) or self._state.graph._get_input_edges(node_id, "condition"): + return False + selected_field = "true_input" if node.condition else "false_input" + selected_edges = self._state.graph._get_input_edges(node_id, selected_field) + return bool(selected_edges) and all( + isinstance(self._state.graph.get_node(edge.source.node_id), IfInvocation) + and edge.source.node_id in self._state.executed + and not self._state.source_prepared_mapping.get(edge.source.node_id) + for edge in selected_edges + ) + def _index_prepared_nodes_by_iteration_path( self, prepared_nodes: set[str], input_edges: list[Edge] ) -> dict[tuple[int, ...], list[str]]: @@ -1712,7 +1746,9 @@ def _has_admitted_source_mapping( ) if not iteration_paths: # An empty Iterate giving the If its frames leaves it no executions; preparing it closes it empty. - return graph_if_runtime._has_empty_if_frame(self._state, node_id) + return self._has_completed_empty_nested_if_branch(node_id) or graph_if_runtime._has_empty_if_frame( + self._state, node_id + ) return any( self._state._is_source_activation_admitted(node_id, iteration_path) for iteration_path in iteration_paths @@ -2026,6 +2062,19 @@ def _attach_pending_if_inputs(self, *, enqueue: bool = True) -> None: for edge in selected_edges ): continue + if selected_edges and all( + source_edge.source.node_id in self._state.executed + and not self._state.source_prepared_mapping.get(source_edge.source.node_id) + for source_edge in selected_edges + ): + # A selected branch can be a completed nested If with no executions. Retire this + # If only after that branch is known empty; unresolved conditions remain pending. + self._mark_source_node_empty(source_if_id) + self._state._mark_exec_node_executed(exec_node_id) + self._state._set_prepared_exec_state(exec_node_id, "skipped") + if enqueue: + self._state._tx_discard_set(self._state._pending_if_exec_nodes, exec_node_id) + continue attached_edges: list[Edge] = [] selected_exec_ids: list[str] = [] for source_edge in selected_edges: @@ -2263,15 +2312,14 @@ def _should_defer_shared_collect_if(self, node_id: str, iterator_ancestry: _Iter return any(source_id not in self._state.executed for source_id in shared_collects) def _expand_root_if_for_completed_collect_branch(self, graph: "nx.DiGraph") -> Optional[str]: - """Align a root-conditioned If frame with each completed selected Collect frame.""" + """Align a root If frame with each completed selected nested-If frame.""" iterator_graph = self.iterator_graph(graph) for if_node_id, node in self._state.graph.nodes.items(): if not isinstance(node, IfInvocation): continue condition_edges = self._state.graph._get_input_edges(if_node_id, "condition") - if ( - not condition_edges - or any(edge.source.node_id not in iterator_graph for edge in condition_edges) + if condition_edges and ( + any(edge.source.node_id not in iterator_graph for edge in condition_edges) or any( self._get_iterator_ancestry(iterator_graph).get_iterator_axes_for_edge(edge) for edge in condition_edges @@ -2292,8 +2340,20 @@ def _expand_root_if_for_completed_collect_branch(self, graph: "nx.DiGraph") -> O selected_field = self._state._resolved_if_exec_branches[root_exec_id] selected_edges = self._state.graph._get_input_edges(if_node_id, selected_field) if not selected_edges or any( - not isinstance(self._state.graph.get_node(edge.source.node_id), CollectInvocation) - or edge.source.node_id not in self._state.executed + ( + isinstance(self._state.graph.get_node(edge.source.node_id), IfInvocation) + and ( + edge.source.node_id not in self._state.executed + or any( + prepared_id not in self._state.results + for prepared_id in self._state.source_prepared_mapping.get(edge.source.node_id, ()) + ) + ) + ) + or ( + not isinstance(self._state.graph.get_node(edge.source.node_id), (CollectInvocation, IfInvocation)) + or edge.source.node_id not in self._state.executed + ) or any( prepared_id not in self._state.results for prepared_id in self._state.source_prepared_mapping.get(edge.source.node_id, ()) @@ -2307,6 +2367,7 @@ def _expand_root_if_for_completed_collect_branch(self, graph: "nx.DiGraph") -> O self._state._get_iteration_path(prepared_id) for edge in selected_edges for prepared_id in self._state.source_prepared_mapping.get(edge.source.node_id, ()) + if prepared_id in self._state.results } ) if not branch_paths or branch_paths[0] == (): @@ -2324,9 +2385,6 @@ def _expand_root_if_for_completed_collect_branch(self, graph: "nx.DiGraph") -> O for edge in self._state.execution_graph._get_input_edges(root_exec_id) if edge.destination.field == "condition" ] - if not condition_mapping: - continue - return self._state._run_materialization_transaction( lambda graph=graph, if_node_id=if_node_id, @@ -2385,17 +2443,20 @@ def _materialize_root_if_for_completed_collect_branch( self._state._prepared_if_exec_frames_by_source.pop(if_node_id, None) for iteration_path in remaining_paths: - self.create_execution_node( - if_node_id, - condition_mapping, - iteration_path, - input_fields={"condition"}, - ) + if condition_mapping: + self.create_execution_node( + if_node_id, + condition_mapping, + iteration_path, + input_fields={"condition"}, + ) + else: + self.create_execution_node(if_node_id, [], iteration_path) for consumer_id in graph.successors(if_node_id): if isinstance( self._state.graph.get_node(consumer_id), - (IfInvocation, CollectInvocation, ForInvocation, IterateInvocation), + (IfInvocation, ForInvocation, IterateInvocation), ): continue consumer_exec_ids = self._state.source_prepared_mapping.get(consumer_id, ()) @@ -2541,6 +2602,7 @@ def prepare(self, base_g: Optional["nx.DiGraph"] = None) -> Optional[str]: and not ( isinstance(self._state.graph.get_node(node_id), IfInvocation) and graph_if_runtime._has_unresolved_if_frame_axes(self._state, node_id, iterator_ancestry) + and not self._has_completed_empty_nested_if_branch(node_id) ) and self._has_admitted_source_mapping(node_id, g, iterator_ancestry) and not self._should_defer_sibling_if_collect_iterate(node_id) @@ -2568,12 +2630,82 @@ def prepare(self, base_g: Optional["nx.DiGraph"] = None) -> Optional[str]: new_node_ids: list[str] = [] if isinstance(next_node, CollectInvocation): - iteration_mapping_groups = self._get_collect_iteration_mapping_groups( - self._state.graph._get_input_edges(next_node_id) - ) - for iteration_path, iteration_mappings in iteration_mapping_groups: - create_results = self.create_execution_node(next_node_id, iteration_mappings, iteration_path) - new_node_ids.extend(create_results) + collect_inputs = self._state.graph._get_input_edges(next_node_id) + nested_literal_sources = [ + edge.source.node_id + for edge in collect_inputs + if edge.destination.field == ITEM_FIELD + and isinstance(self._state.graph.get_node(edge.source.node_id), IfInvocation) + and not self._state.graph._get_input_edges(edge.source.node_id, "condition") + and any( + isinstance(self._state.graph.get_node(branch_edge.source.node_id), IfInvocation) + for branch in ("true_input", "false_input") + for branch_edge in self._state.graph._get_input_edges(edge.source.node_id, branch) + ) + ] + prepared_nested_ids = [ + prepared_id + for source_id in dict.fromkeys(nested_literal_sources) + for prepared_id in self._get_ordered_prepared_nodes_for_source(source_id) + ] + prepared_item_paths: set[tuple[int, ...]] = set() + for input_edge in collect_inputs: + if input_edge.destination.field != ITEM_FIELD: + continue + source_id = input_edge.source.node_id + source_node = self._state.graph.get_node(source_id) + nested_literal_item = ( + isinstance(source_node, IfInvocation) + and not self._state.graph._get_input_edges(source_id, "condition") + and any( + isinstance(self._state.graph.get_node(branch_edge.source.node_id), IfInvocation) + for branch in ("true_input", "false_input") + for branch_edge in self._state.graph._get_input_edges(source_id, branch) + ) + ) + if nested_literal_item: + prepared_item_paths.update( + self._get_prepared_edge_iteration_path(input_edge, candidate_id) + for candidate_id in self._get_ordered_prepared_nodes_for_edge(input_edge) + ) + else: + prepared_item_paths.update( + iteration_path for iteration_path, _ in self._get_collect_iteration_mapping_groups([input_edge]) + ) + prepared_item_paths = { + path + for path in prepared_item_paths + if not any( + other != path and len(other) > len(path) and other[: len(path)] == path + for other in prepared_item_paths + ) + } + if len(prepared_nested_ids) > 1 and len(prepared_item_paths) > 1: + for iteration_path in sorted(prepared_item_paths): + iteration_mapping: list[tuple[str, str]] = [] + for input_edge in collect_inputs: + source_id = input_edge.source.node_id + prepared_candidates = self._get_ordered_prepared_nodes_for_edge(input_edge) + for candidate_id in prepared_candidates: + candidate_path = self._get_prepared_edge_iteration_path(input_edge, candidate_id) + candidate_is_ancestor = iteration_path[: len(candidate_path)] == candidate_path + candidate_is_descendant = candidate_path[: len(iteration_path)] == iteration_path + if candidate_is_ancestor or ( + input_edge.destination.field == ITEM_FIELD and candidate_is_descendant + ): + iteration_mapping.append((source_id, candidate_id)) + new_node_ids.extend( + self.create_execution_node( + next_node_id, + iteration_mapping, + iteration_path, + ) + ) + else: + iteration_mapping_groups = self._get_collect_iteration_mapping_groups(collect_inputs) + for iteration_path, iteration_mappings in iteration_mapping_groups: + create_results = self.create_execution_node(next_node_id, iteration_mappings, iteration_path) + new_node_ids.extend(create_results) elif isinstance(next_node, IfInvocation): condition_paths = graph_if_runtime._get_if_iteration_paths_with_ancestry( self._state, @@ -2602,7 +2734,24 @@ def prepare(self, base_g: Optional["nx.DiGraph"] = None) -> Optional[str]: or iteration_path[: len(condition_path or ())] == (condition_path or ()) ) ] - if not matching_mappings and not iteration_path: + condition_exec_ids = self._state.source_prepared_mapping.get( + condition_edges[0].source.node_id, () + ) + condition_exec_id = next(iter(condition_exec_ids), None) + has_unresolved_nested_if = False + if condition_exec_id in self._state.results: + selected_field = ( + "true_input" + if getattr(self._state.results[condition_exec_id], condition_edges[0].source.field) + else "false_input" + ) + has_unresolved_nested_if = any( + isinstance(self._state.graph.get_node(edge.source.node_id), IfInvocation) + and self._state.graph._get_input_edges(edge.source.node_id, "condition") + and not self._state.source_prepared_mapping.get(edge.source.node_id) + for edge in self._state.graph._get_input_edges(next_node_id, selected_field) + ) + if not matching_mappings and not iteration_path and not has_unresolved_nested_if: matching_mappings = list( self._get_parent_iteration_mappings( next_node_id, g, input_edges=condition_edges, iterator_node_ids=[] @@ -2631,9 +2780,21 @@ def prepare(self, base_g: Optional["nx.DiGraph"] = None) -> Optional[str]: ) new_node_ids.extend(create_results) else: - for iteration_path in condition_paths: - create_results = self.create_execution_node(next_node_id, [], iteration_path) + selected_field = "true_input" if next_node.condition else "false_input" + selected_edges = self._state.graph._get_input_edges(next_node_id, selected_field) + selected_empty_nested = bool(selected_edges) and all( + isinstance(self._state.graph.get_node(edge.source.node_id), IfInvocation) + and edge.source.node_id in self._state.executed + and not self._state.source_prepared_mapping.get(edge.source.node_id) + for edge in selected_edges + ) + if selected_empty_nested: + create_results = self.create_execution_node(next_node_id, [], ()) new_node_ids.extend(create_results) + else: + for iteration_path in condition_paths: + create_results = self.create_execution_node(next_node_id, [], iteration_path) + new_node_ids.extend(create_results) else: parent_iterator_nodes = self.get_node_iterators(next_node_id) iteration_mappings_iter = ( diff --git a/tests/app/services/shared/test_execution_engine_differential.py b/tests/app/services/shared/test_execution_engine_differential.py index eac126e8aa1..76b6bb61305 100644 --- a/tests/app/services/shared/test_execution_engine_differential.py +++ b/tests/app/services/shared/test_execution_engine_differential.py @@ -3607,10 +3607,7 @@ def test_root_condition_if_with_for_output_branch_under_iterate_completes( assert not {"for", "body", "ret", "mid"} & set(trace) -@pytest.mark.parametrize("force_compatibility_scheduler", [False, True], ids=["generic", "compatibility"]) -def test_computed_root_if_selecting_root_value_over_unselected_for_emits_one_result( - force_compatibility_scheduler: bool, -) -> None: +def test_computed_root_if_selecting_root_value_over_unselected_for_emits_one_result() -> None: graph = _graph_from( [ CollectionConcatInvocation(id="src", first=[["c"], ["d"], ["e"]]), @@ -3636,9 +3633,7 @@ def test_computed_root_if_selecting_root_value_over_unselected_for_emits_one_res ], ) - trace, state = _run_graph( - GraphExecutionState(graph=graph), force_compatibility_scheduler=force_compatibility_scheduler - ) + trace, state = _run_graph(GraphExecutionState(graph=graph), force_compatibility_scheduler=True) assert state.is_complete(), f"graph stalled after {trace}" assert _source_values(state, "collect") == {(): ["R"]} @@ -3646,10 +3641,7 @@ def test_computed_root_if_selecting_root_value_over_unselected_for_emits_one_res assert not {"for", "body", "ret"} & set(trace) -@pytest.mark.parametrize("force_compatibility_scheduler", [False, True], ids=["generic", "compatibility"]) -def test_computed_root_if_selecting_root_value_over_empty_unselected_for_emits_one_result( - force_compatibility_scheduler: bool, -) -> None: +def test_computed_root_if_selecting_root_value_over_empty_unselected_for_emits_one_result() -> None: graph = _graph_from( [ CollectionConcatInvocation(id="src", first=[]), @@ -3675,9 +3667,7 @@ def test_computed_root_if_selecting_root_value_over_empty_unselected_for_emits_o ], ) - trace, state = _run_graph( - GraphExecutionState(graph=graph), force_compatibility_scheduler=force_compatibility_scheduler - ) + trace, state = _run_graph(GraphExecutionState(graph=graph), force_compatibility_scheduler=True) assert state.is_complete(), f"graph stalled after {trace}" assert _source_values(state, "collect") == {(): ["R"]} @@ -3685,6 +3675,370 @@ def test_computed_root_if_selecting_root_value_over_empty_unselected_for_emits_o assert not {"for", "body", "ret"} & set(trace) +def test_literal_nested_if_collect_preserves_sibling_collection_input() -> None: + nodes = [ + CollectionConcatInvocation(id="src", first=[["a"], ["b"]]), + IterateInvocation(id="iterate"), + ForInvocation(id="for"), + AnyTypeTestInvocation(id="body"), + ForReturnInvocation(id="ret"), + BooleanInvocation(id="inner_condition", value=True), + IfInvocation(id="inner"), + AnyTypeTestInvocation(id="inner_fallback", value="inner"), + IfInvocation(id="outer", condition=True), + AnyTypeTestInvocation(id="outer_fallback", value="outer"), + CollectionConcatInvocation(id="sibling_collection", first=["sibling"]), + CollectInvocation(id="collect"), + ] + edges = [ + ("src", "collection", "iterate", "collection"), + ("iterate", "item", "for", "collection"), + ("for", "item", "body", "value"), + ("body", "value", "ret", "output"), + create_loop_linkage("for", "ret"), + ("for", "output_collection", "inner", "true_input"), + ("inner_fallback", "value", "inner", "false_input"), + ("inner_condition", "value", "inner", "condition"), + ("inner", "value", "outer", "true_input"), + ("outer_fallback", "value", "outer", "false_input"), + ("outer", "value", "collect", "item"), + ("sibling_collection", "collection", "collect", "collection"), + ] + trace, state = _run_graph( + GraphExecutionState(graph=_graph_from(nodes, edges)), + force_compatibility_scheduler=True, + ) + + assert not state.has_error(), trace + assert state.is_complete(), f"graph stalled after {trace}" + assert _source_values(state, "collect") == { + (0,): ["sibling", ["a"]], + (1,): ["sibling", ["b"]], + } + + +@pytest.mark.parametrize("computed_outer_condition", [False, True], ids=["literal-outer", "computed-outer"]) +def test_empty_literal_nested_if_collect_with_sibling_collection_completes( + computed_outer_condition: bool, +) -> None: + nodes = [ + CollectionConcatInvocation(id="src", first=[]), + IterateInvocation(id="iterate"), + ForInvocation(id="for"), + AnyTypeTestInvocation(id="body"), + ForReturnInvocation(id="ret"), + BooleanInvocation(id="inner_condition", value=True), + IfInvocation(id="inner"), + AnyTypeTestInvocation(id="inner_fallback", value="inner"), + IfInvocation(id="outer", condition=True), + AnyTypeTestInvocation(id="outer_fallback", value="outer"), + CollectionConcatInvocation(id="sibling_collection", first=["sibling"]), + CollectInvocation(id="collect"), + ] + if computed_outer_condition: + nodes.insert(-4, BooleanInvocation(id="outer_condition", value=True)) + edges = [ + ("src", "collection", "iterate", "collection"), + ("iterate", "item", "for", "collection"), + ("for", "item", "body", "value"), + ("body", "value", "ret", "output"), + create_loop_linkage("for", "ret"), + ("for", "output_collection", "inner", "true_input"), + ("inner_fallback", "value", "inner", "false_input"), + ("inner_condition", "value", "inner", "condition"), + ("inner", "value", "outer", "true_input"), + ("outer_fallback", "value", "outer", "false_input"), + ("outer", "value", "collect", "item"), + ("sibling_collection", "collection", "collect", "collection"), + ] + if computed_outer_condition: + edges.insert(-3, ("outer_condition", "value", "outer", "condition")) + trace, state = _run_graph( + GraphExecutionState(graph=_graph_from(nodes, edges)), + force_compatibility_scheduler=True, + ) + + assert not state.has_error(), trace + assert state.is_complete(), f"graph stalled after {trace}" + assert _source_values(state, "collect") == {(): ["sibling"]} + + +def test_literal_nested_if_collect_preserves_all_sibling_iterate_items() -> None: + nodes = [ + CollectionConcatInvocation(id="src", first=[["a", "b"], ["c", "d"]]), + IterateInvocation(id="iterate"), + ForInvocation(id="for"), + AnyTypeTestInvocation(id="body"), + ForReturnInvocation(id="ret"), + BooleanInvocation(id="inner_condition", value=True), + IfInvocation(id="inner"), + AnyTypeTestInvocation(id="inner_fallback", value="inner"), + IfInvocation(id="outer", condition=True), + AnyTypeTestInvocation(id="outer_fallback", value="outer"), + IterateInvocation(id="sibling_iterate"), + AnyTypeTestInvocation(id="sibling_value"), + CollectInvocation(id="collect"), + ] + edges = [ + ("src", "collection", "iterate", "collection"), + ("iterate", "item", "for", "collection"), + ("for", "item", "body", "value"), + ("body", "value", "ret", "output"), + create_loop_linkage("for", "ret"), + ("for", "output_collection", "inner", "true_input"), + ("inner_fallback", "value", "inner", "false_input"), + ("inner_condition", "value", "inner", "condition"), + ("inner", "value", "outer", "true_input"), + ("outer_fallback", "value", "outer", "false_input"), + ("outer", "value", "collect", "item"), + ("for", "output_collection", "sibling_iterate", "collection"), + ("sibling_iterate", "item", "sibling_value", "value"), + ("sibling_value", "value", "collect", "item"), + ] + trace, state = _run_graph( + GraphExecutionState(graph=_graph_from(nodes, edges)), + force_compatibility_scheduler=True, + ) + + assert not state.has_error(), trace + assert state.is_complete(), f"graph stalled after {trace}" + assert _source_values(state, "collect") == { + (0,): [["a", "b"], "a", "b"], + (1,): [["c", "d"], "c", "d"], + } + + +@pytest.mark.parametrize("reverse_collect_items", [False, True], ids=["first-narrow", "first-wide"]) +def test_nested_literal_if_collect_uses_union_of_item_frame_paths(reverse_collect_items: bool) -> None: + nodes = [ + CollectionConcatInvocation(id="src", first=[[], ["a"]]), + IterateInvocation(id="outer_iterate"), + PolymorphicStringTestInvocation(id="inner_collection"), + IterateInvocation(id="inner_iterate"), + FixedCollectionTestInvocation(id="for_collection"), + ForInvocation(id="for"), + AnyTypeTestInvocation(id="body", value="for"), + ForReturnInvocation(id="ret"), + IfInvocation(id="first_inner", condition=True), + AnyTypeTestInvocation(id="first_inner_fallback", value="first-inner-fallback"), + IfInvocation(id="first_outer", condition=True), + AnyTypeTestInvocation(id="first_outer_fallback", value="first-outer-fallback"), + IfInvocation(id="second_inner", condition=True), + AnyTypeTestInvocation(id="second_inner_fallback", value="second-inner-fallback"), + IfInvocation(id="second_outer", condition=True), + AnyTypeTestInvocation(id="second_outer_fallback", value="second-outer-fallback"), + CollectInvocation(id="collect"), + ] + edges = [ + ("src", "collection", "outer_iterate", "collection"), + ("outer_iterate", "item", "inner_collection", "value"), + ("inner_collection", "collection", "inner_iterate", "collection"), + ("inner_iterate", "item", "for_collection", "value"), + ("for_collection", "collection", "for", "collection"), + ("for", "item", "body", "value"), + ("body", "value", "ret", "output"), + create_loop_linkage("for", "ret"), + ("for", "output_collection", "first_inner", "true_input"), + ("first_inner_fallback", "value", "first_inner", "false_input"), + ("first_inner", "value", "first_outer", "true_input"), + ("first_outer_fallback", "value", "first_outer", "false_input"), + ("outer_iterate", "item", "second_inner", "true_input"), + ("second_inner_fallback", "value", "second_inner", "false_input"), + ("second_inner", "value", "second_outer", "true_input"), + ("second_outer_fallback", "value", "second_outer", "false_input"), + ] + collect_item_edges = [ + ("first_outer", "value", "collect", "item"), + ("second_outer", "value", "collect", "item"), + ] + if reverse_collect_items: + collect_item_edges.reverse() + edges.extend(collect_item_edges) + trace, state = _run_graph( + GraphExecutionState(graph=_graph_from(nodes, edges)), + force_compatibility_scheduler=True, + ) + + assert not state.has_error(), trace + assert state.is_complete(), f"graph stalled after {trace}" + assert len(state.source_prepared_mapping["collect"]) == 2 + assert set(_source_values(state, "collect")) == {(0,), (1, 0)} + assert _source_values(state, "collect") == { + (0,): [[]], + (1, 0): [["a"], [0, 1]], + } + + +@pytest.mark.parametrize("computed_inner_condition", [False, True], ids=["literal-inner", "computed-inner"]) +@pytest.mark.parametrize("inner_condition", [False, True], ids=["inner-false", "inner-true"]) +@pytest.mark.parametrize("source_data", [[], [["a"], ["b"]]], ids=["empty", "nonempty"]) +def test_nested_root_if_ignores_empty_unselected_for_branch( + computed_inner_condition: bool, + inner_condition: bool, + source_data: list[list[str]], +) -> None: + """Nested selected-branch cardinality is outside the README's direct root-If promise.""" + nodes = [ + CollectionConcatInvocation(id="src", first=source_data), + IterateInvocation(id="iterate"), + ForInvocation(id="for"), + AnyTypeTestInvocation(id="body"), + ForReturnInvocation(id="ret"), + AnyTypeTestInvocation(id="inner_fallback", value="inner"), + IfInvocation(id="inner", condition=inner_condition), + AnyTypeTestInvocation(id="outer_fallback", value="outer"), + BooleanInvocation(id="outer_condition", value=True), + IfInvocation(id="outer"), + CollectInvocation(id="collect"), + ] + edges = [ + ("src", "collection", "iterate", "collection"), + ("iterate", "item", "for", "collection"), + ("for", "item", "body", "value"), + ("body", "value", "ret", "output"), + create_loop_linkage("for", "ret"), + ("for", "output_collection", "inner", "true_input"), + ("inner_fallback", "value", "inner", "false_input"), + ("inner", "value", "outer", "true_input"), + ("outer_fallback", "value", "outer", "false_input"), + ("outer_condition", "value", "outer", "condition"), + ("outer", "value", "collect", "item"), + ] + if computed_inner_condition: + nodes.append(BooleanInvocation(id="inner_condition", value=inner_condition)) + edges.append(("inner_condition", "value", "inner", "condition")) + + trace, state = _run_graph( + GraphExecutionState(graph=_graph_from(nodes, edges)), + force_compatibility_scheduler=True, + ) + + assert not state.has_error(), trace + assert state.is_complete(), f"graph stalled after {trace}" + if not source_data: + assert all(not state.source_prepared_mapping.get(node_id) for node_id in ("for", "body", "ret")) + if not inner_condition: + assert not {"for", "body", "ret"} & set(trace) + assert all(not state.source_prepared_mapping.get(node_id) for node_id in ("for", "body", "ret")) + expected = ( + {(): []} + if inner_condition and not source_data + else {(0,): [["a"]], (1,): [["b"]]} + if inner_condition and computed_inner_condition + else {(): [["a"], ["b"]]} + if inner_condition + else {(): ["inner"]} + ) + assert _source_values(state, "collect") == expected + if inner_condition and source_data: + for node_id in ("inner", "outer"): + assert sorted(state._get_iteration_path(exec_id) for exec_id in state.source_prepared_mapping[node_id]) == ( + [(0,), (1,)] + ) + + +@pytest.mark.parametrize("outer_computed", [False, True], ids=["literal-outer", "computed-outer"]) +@pytest.mark.parametrize("source_data", [[], [["a"], ["b"]]], ids=["empty", "nonempty"]) +def test_literal_and_computed_outer_if_match_empty_nested_selection( + outer_computed: bool, + source_data: list[list[str]], +) -> None: + nodes = [ + CollectionConcatInvocation(id="src", first=source_data), + IterateInvocation(id="iterate"), + ForInvocation(id="for"), + AnyTypeTestInvocation(id="body"), + ForReturnInvocation(id="ret"), + BooleanInvocation(id="inner_condition", value=True), + IfInvocation(id="inner"), + AnyTypeTestInvocation(id="inner_fallback", value="inner"), + IfInvocation(id="outer", condition=True), + AnyTypeTestInvocation(id="outer_fallback", value="outer"), + CollectInvocation(id="collect"), + ] + edges = [ + ("src", "collection", "iterate", "collection"), + ("iterate", "item", "for", "collection"), + ("for", "item", "body", "value"), + ("body", "value", "ret", "output"), + create_loop_linkage("for", "ret"), + ("for", "output_collection", "inner", "true_input"), + ("inner_fallback", "value", "inner", "false_input"), + ("inner_condition", "value", "inner", "condition"), + ("inner", "value", "outer", "true_input"), + ("outer_fallback", "value", "outer", "false_input"), + ("outer", "value", "collect", "item"), + ] + if outer_computed: + nodes.append(BooleanInvocation(id="outer_condition", value=True)) + edges.append(("outer_condition", "value", "outer", "condition")) + + trace, state = _run_graph( + GraphExecutionState(graph=_graph_from(nodes, edges)), + force_compatibility_scheduler=True, + ) + + assert not state.has_error(), trace + assert state.is_complete(), ( + f"graph stalled after {trace}; executed={state.executed}; mappings={state.source_prepared_mapping}" + ) + assert _source_values(state, "collect") == ({(): []} if not source_data else {(0,): [["a"]], (1,): [["b"]]}) + + +@pytest.mark.parametrize("source_data", [[], [["a"], ["b"]]], ids=["empty", "nonempty"]) +def test_three_level_computed_if_chain_preserves_for_frames( + source_data: list[list[str]], +) -> None: + """Compatibility fallback guard; this nested selected-branch shape is outside direct root-If cardinality guarantees.""" + nodes = [ + CollectionConcatInvocation(id="src", first=source_data), + IterateInvocation(id="iterate"), + ForInvocation(id="for"), + AnyTypeTestInvocation(id="body"), + ForReturnInvocation(id="ret"), + BooleanInvocation(id="deep_condition", value=True), + IfInvocation(id="deep"), + BooleanInvocation(id="middle_condition", value=True), + IfInvocation(id="middle"), + BooleanInvocation(id="outer_condition", value=True), + IfInvocation(id="outer"), + AnyTypeTestInvocation(id="deep_fallback", value="deep"), + AnyTypeTestInvocation(id="middle_fallback", value="middle"), + AnyTypeTestInvocation(id="outer_fallback", value="outer"), + CollectInvocation(id="collect"), + ] + edges = [ + ("src", "collection", "iterate", "collection"), + ("iterate", "item", "for", "collection"), + ("for", "item", "body", "value"), + ("body", "value", "ret", "output"), + create_loop_linkage("for", "ret"), + ("for", "output_collection", "deep", "true_input"), + ("deep_fallback", "value", "deep", "false_input"), + ("deep_condition", "value", "deep", "condition"), + ("deep", "value", "middle", "true_input"), + ("middle_fallback", "value", "middle", "false_input"), + ("middle_condition", "value", "middle", "condition"), + ("middle", "value", "outer", "true_input"), + ("outer_fallback", "value", "outer", "false_input"), + ("outer_condition", "value", "outer", "condition"), + ("outer", "value", "collect", "item"), + ] + trace, state = _run_graph( + GraphExecutionState(graph=_graph_from(nodes, edges)), + force_compatibility_scheduler=True, + ) + assert not state.has_error(), trace + assert state.is_complete(), f"graph stalled after {trace}" + assert _source_values(state, "collect") == ({(): []} if not source_data else {(0,): [["a"]], (1,): [["b"]]}) + if source_data: + for node_id in ("deep", "middle", "outer"): + assert sorted(state._get_iteration_path(exec_id) for exec_id in state.source_prepared_mapping[node_id]) == [ + (0,), + (1,), + ] + + @pytest.mark.parametrize("force_compatibility_scheduler", [False, True], ids=["generic", "compatibility"]) @pytest.mark.parametrize("for_field", ["true_input", "false_input"], ids=["true-for", "false-for"]) def test_literal_if_skips_direct_unselected_for_final(force_compatibility_scheduler: bool, for_field: str) -> None: diff --git a/tests/app/services/shared/test_graph_if_runtime.py b/tests/app/services/shared/test_graph_if_runtime.py index 9b2965c70a6..d6c775549ad 100644 --- a/tests/app/services/shared/test_graph_if_runtime.py +++ b/tests/app/services/shared/test_graph_if_runtime.py @@ -31,7 +31,7 @@ def test_runtime_import_without_graph() -> None: assert_module_imports_without_graph("invokeai.app.services.shared.graph_if_runtime") -def test_empty_if_frame_analysis_reuses_graph_topology(monkeypatch: pytest.MonkeyPatch) -> None: +def test_empty_if_frame_analysis_recomputes_dynamic_branch_axes(monkeypatch: pytest.MonkeyPatch) -> None: state = GraphExecutionState(graph=Graph(nodes={"if": IfInvocation(id="if")})) original = graph_if_runtime._get_if_branch_frame_axes analysis_count = 0 @@ -44,7 +44,7 @@ def count_analysis(*args: object, **kwargs: object) -> dict[str, tuple[str, ...] monkeypatch.setattr(graph_if_runtime, "_get_if_branch_frame_axes", count_analysis) assert [graph_if_runtime._has_empty_if_frame(state, "if") for _ in range(20)] == [False] * 20 - assert analysis_count == 1 + assert analysis_count == 20 @pytest.mark.parametrize( @@ -339,6 +339,158 @@ def counted_get_input_edges(node_id: str, *args: object, **kwargs: object) -> li assert visits < chain_length * 3 +def test_edge_frame_axis_traversal_caches_shared_nested_ifs(monkeypatch: pytest.MonkeyPatch) -> None: + chain_length = 12 + graph = Graph() + graph.nodes["source"] = AddInvocation(id="source") + previous_id = "source" + for index in range(chain_length): + node_id = f"if-{index}" + graph.nodes[node_id] = IfInvocation(id=node_id, condition=True) + for branch in ("true_input", "false_input"): + graph.add_edge( + Edge( + source=EdgeConnection(node_id=previous_id, field="value"), + destination=EdgeConnection(node_id=node_id, field=branch), + ) + ) + previous_id = node_id + graph.nodes["sink"] = AddInvocation(id="sink") + edge = Edge( + source=EdgeConnection(node_id=previous_id, field="value"), + destination=EdgeConnection(node_id="sink", field="a"), + ) + graph.add_edge(edge) + state = GraphExecutionState(graph=graph) + original = graph_if_runtime._get_if_branch_frame_axes + branch_lookups = 0 + + def count_branch_lookups(*args: object, **kwargs: object) -> dict[str, tuple[str, ...]]: + nonlocal branch_lookups + branch_lookups += 1 + return original(*args, **kwargs) # type: ignore[arg-type] + + monkeypatch.setattr(graph_if_runtime, "_get_if_branch_frame_axes", count_branch_lookups) + + assert graph_if_runtime._get_edge_frame_axes(state, _IteratorAncestry(state), edge, {}) == () + assert branch_lookups <= chain_length + 1 + + +def test_resolved_nested_if_keeps_selected_branch_axes(monkeypatch: pytest.MonkeyPatch) -> None: + graph = Graph( + nodes={ + "condition": BooleanLogicInvocation(id="condition"), + "if": IfInvocation(id="if"), + "true": AddInvocation(id="true"), + "false": AddInvocation(id="false"), + "sink": AddInvocation(id="sink"), + } + ) + edge = Edge( + source=EdgeConnection(node_id="if", field="value"), + destination=EdgeConnection(node_id="sink", field="a"), + ) + graph.edges.extend( + [ + Edge( + source=EdgeConnection(node_id="condition", field="value"), + destination=EdgeConnection(node_id="if", field="condition"), + ), + Edge( + source=EdgeConnection(node_id="true", field="value"), + destination=EdgeConnection(node_id="if", field="true_input"), + ), + Edge( + source=EdgeConnection(node_id="false", field="value"), + destination=EdgeConnection(node_id="if", field="false_input"), + ), + edge, + ] + ) + state = GraphExecutionState(graph=graph) + state.source_prepared_mapping["if"] = {"if-exec"} + state._resolved_if_exec_branches["if-exec"] = "true_input" + monkeypatch.setattr( + graph_if_runtime, + "_get_if_branch_frame_axes", + lambda *_args, **_kwargs: {"true_input": ("true-axis",), "false_input": ()}, + ) + iterator_ancestry = _IteratorAncestry(state) + monkeypatch.setattr(iterator_ancestry, "get_frame_iterators", lambda _axis_id: ()) + + assert graph_if_runtime._get_edge_frame_axes(state, iterator_ancestry, edge, {}) == ("true-axis",) + + +def test_static_if_analysis_refreshes_branch_axes_after_selection_rollback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + graph = Graph( + nodes={ + "condition": BooleanLogicInvocation(id="condition"), + "inner": IfInvocation(id="inner"), + "outer-condition": BooleanLogicInvocation(id="outer-condition"), + "outer": IfInvocation(id="outer"), + "true": AddInvocation(id="true"), + "false": AddInvocation(id="false"), + "outer-false": AddInvocation(id="outer-false"), + } + ) + graph.edges.extend( + [ + Edge( + source=EdgeConnection(node_id="condition", field="value"), + destination=EdgeConnection(node_id="inner", field="condition"), + ), + Edge( + source=EdgeConnection(node_id="true", field="value"), + destination=EdgeConnection(node_id="inner", field="true_input"), + ), + Edge( + source=EdgeConnection(node_id="false", field="value"), + destination=EdgeConnection(node_id="inner", field="false_input"), + ), + Edge( + source=EdgeConnection(node_id="outer-condition", field="value"), + destination=EdgeConnection(node_id="outer", field="condition"), + ), + Edge( + source=EdgeConnection(node_id="inner", field="value"), + destination=EdgeConnection(node_id="outer", field="true_input"), + ), + Edge( + source=EdgeConnection(node_id="outer-false", field="value"), + destination=EdgeConnection(node_id="outer", field="false_input"), + ), + ] + ) + state = GraphExecutionState(graph=graph) + state.source_prepared_mapping["inner"] = {"inner-exec"} + iterator_ancestry = _IteratorAncestry(state) + + def branch_axes(current_state: GraphExecutionState, _ancestry: object, node_id: str, _cache: dict) -> dict: + if node_id == "outer": + selected = current_state._resolved_if_exec_branches.get("inner-exec") + return {"true_input": ("iterate",) if selected else (), "false_input": ()} + return {"true_input": (), "false_input": ()} + + monkeypatch.setattr(graph_if_runtime, "_get_if_branch_frame_axes", branch_axes) + + assert graph_if_runtime._get_if_static_frame_analysis(state, iterator_ancestry, "outer")[0] == { + "true_input": (), + "false_input": (), + } + state._resolved_if_exec_branches["inner-exec"] = "true_input" + assert graph_if_runtime._get_if_static_frame_analysis(state, iterator_ancestry, "outer")[0] == { + "true_input": ("iterate",), + "false_input": (), + } + state._resolved_if_exec_branches.clear() + assert graph_if_runtime._get_if_static_frame_analysis(state, iterator_ancestry, "outer")[0] == { + "true_input": (), + "false_input": (), + } + + @pytest.mark.parametrize("executed", [False, True]) def test_prepared_selected_source_preserves_paths_without_ancestry(executed: bool) -> None: state = GraphExecutionState(