Summary
When two independent iterators both feed a node whose collection output is then iterated again, the body of the inner iteration is prepared twice for the off-diagonal iteration paths. The duplicate exec nodes carry the same iteration path, both execute, and both land in the downstream collector — so the collected list contains every item twice for those branches.
With 2 × 2 outer items the body runs 12 times instead of 8, and two of the four collected groups are doubled.
Found while adversarially reviewing #9483. It is not caused by that PR — reproduced identically at a21d74b8bd (its base) and at the PR head.
Repro
src1 → iter_a ─┐
├→ pairer → inner_iter → body → collect
src2 → iter_b ─┘
Script (run from repo root with PYTHONPATH=.)
from typing import Any
from invokeai.app.invocations.baseinvocation import BaseInvocation, InvocationContext, invocation
from invokeai.app.invocations.fields import InputField
from invokeai.app.services.shared.graph import CollectInvocation, Graph, GraphExecutionState, IterateInvocation
from tests.test_nodes import (
PromptCollectionTestInvocation,
PromptCollectionTestInvocationOutput,
PromptTestInvocation,
create_edge,
run_session_with_mock_context,
)
@invocation("issue_pair", version="1.0.0")
class PairTestInvocation(BaseInvocation):
a: Any = InputField(default=None)
b: Any = InputField(default=None)
def invoke(self, context: InvocationContext) -> PromptCollectionTestInvocationOutput:
return PromptCollectionTestInvocationOutput(collection=[f"{self.a}{self.b}.0", f"{self.a}{self.b}.1"])
g = Graph()
for n in [
PromptCollectionTestInvocation(id="src1", collection=["a", "b"]),
PromptCollectionTestInvocation(id="src2", collection=["x", "y"]),
IterateInvocation(id="iter_a"),
IterateInvocation(id="iter_b"),
PairTestInvocation(id="pairer"),
IterateInvocation(id="inner_iter"),
PromptTestInvocation(id="body"),
CollectInvocation(id="collect"),
]:
g.add_node(n)
for e in [
("src1", "collection", "iter_a", "collection"),
("src2", "collection", "iter_b", "collection"),
("iter_a", "item", "pairer", "a"),
("iter_b", "item", "pairer", "b"),
("pairer", "collection", "inner_iter", "collection"),
("inner_iter", "item", "body", "prompt"),
("body", "prompt", "collect", "item"),
]:
g.add_edge(create_edge(*e))
session = GraphExecutionState(graph=g)
run_session_with_mock_context(session)
print("inner_iter instances:", len(session.source_prepared_mapping["inner_iter"]), "(expected 8)")
print("body instances: ", len(session.source_prepared_mapping["body"]), "(expected 8)")
print("body iteration paths:", sorted(session._get_iteration_path(p) for p in session.source_prepared_mapping["body"]))
for p in sorted(session.source_prepared_mapping["collect"], key=session._get_iteration_path):
print(" collect", session._get_iteration_path(p), session.results[p].collection)
Observed
inner_iter instances: 8 (expected 8)
body instances: 12 (expected 8)
body iteration paths: [(0,0,0), (0,0,1), (0,1,0), (0,1,0), (0,1,1), (0,1,1),
(1,0,0), (1,0,0), (1,0,1), (1,0,1), (1,1,0), (1,1,1)]
collect (0, 0) ['ax.0', 'ax.1']
collect (0, 1) ['ay.0', 'ay.0', 'ay.1', 'ay.1']
collect (1, 0) ['bx.0', 'bx.0', 'bx.1', 'bx.1']
collect (1, 1) ['by.0', 'by.1']
Note inner_iter is correct at 8 instances — the duplication is introduced when body is prepared against them. The diagonal paths (0,0,*) and (1,1,*) are correct; only the off-diagonal ones are doubled.
Expected
body instances: 8
collect (0, 0) ['ax.0', 'ax.1']
collect (0, 1) ['ay.0', 'ay.1']
collect (1, 0) ['bx.0', 'bx.1']
collect (1, 1) ['by.0', 'by.1']
Analysis
For body, get_node_iterators() returns all three iterators (inner_iter, iter_a, iter_b), so _get_parent_iteration_mappings() takes the itertools.product path over their prepared nodes — 2 × 2 × 8 = 32 combinations. For each it resolves body's single parent inner_iter via _get_indexed_iteration_node() / get_iteration_node(), and every combination that resolves yields a mapping that prepare() turns into a body exec node.
Instrumenting that call confirms it yields 12 mappings, with each off-diagonal inner_iter exec node selected twice:
body: iterators=['inner_iter', 'iter_a', 'iter_b'] mappings_yielded=12
(0, 0, 0): 1 (0, 1, 0): 2 (1, 0, 0): 2 (1, 1, 0): 1
(0, 0, 1): 1 (0, 1, 1): 2 (1, 0, 1): 2 (1, 1, 1): 1
The (iter_a, iter_b) pair drawn from the product is never constrained to agree with the prefix of the inner_iter exec node it resolves to. Matching goes through _matches_parent_iterators(), which asks nx.has_path(execution_graph, iterator_exec_id, candidate) — satisfied by any ancestor iterator exec node on the path — so a combination whose pair disagrees with the resolved path is indistinguishable from the one that agrees. A fix should reject a mapping whose product-selected iterator exec nodes are inconsistent with the resolved parent's iteration path.
Environment
Reproduced at a21d74b8bd (current main) and at abb324a7d0 (#9483 head), identical output. Python 3.12.13.
🤖 Generated with Claude Code
Summary
When two independent iterators both feed a node whose collection output is then iterated again, the body of the inner iteration is prepared twice for the off-diagonal iteration paths. The duplicate exec nodes carry the same iteration path, both execute, and both land in the downstream collector — so the collected list contains every item twice for those branches.
With 2 × 2 outer items the body runs 12 times instead of 8, and two of the four collected groups are doubled.
Found while adversarially reviewing #9483. It is not caused by that PR — reproduced identically at
a21d74b8bd(its base) and at the PR head.Repro
Script (run from repo root with
PYTHONPATH=.)Observed
Note
inner_iteris correct at 8 instances — the duplication is introduced whenbodyis prepared against them. The diagonal paths(0,0,*)and(1,1,*)are correct; only the off-diagonal ones are doubled.Expected
Analysis
For
body,get_node_iterators()returns all three iterators (inner_iter,iter_a,iter_b), so_get_parent_iteration_mappings()takes theitertools.productpath over their prepared nodes — 2 × 2 × 8 = 32 combinations. For each it resolvesbody's single parentinner_itervia_get_indexed_iteration_node()/get_iteration_node(), and every combination that resolves yields a mapping thatprepare()turns into abodyexec node.Instrumenting that call confirms it yields 12 mappings, with each off-diagonal
inner_iterexec node selected twice:The
(iter_a, iter_b)pair drawn from the product is never constrained to agree with the prefix of theinner_iterexec node it resolves to. Matching goes through_matches_parent_iterators(), which asksnx.has_path(execution_graph, iterator_exec_id, candidate)— satisfied by any ancestor iterator exec node on the path — so a combination whose pair disagrees with the resolved path is indistinguishable from the one that agrees. A fix should reject a mapping whose product-selected iterator exec nodes are inconsistent with the resolved parent's iteration path.Environment
Reproduced at
a21d74b8bd(currentmain) and atabb324a7d0(#9483 head), identical output. Python 3.12.13.🤖 Generated with Claude Code