fix(inspect): walk each sampler's latent ancestry once per resolution

Every selector branch run and candidate re-walked the same latent
ancestry, so conditioning-carrying origins such as WanImageToVideo turned
a three-selector graph from ambiguous into work-limit. The walk outcome is
now cached per sampler; branch runs of one candidate pay for it once and
other candidates are charged its visit cost, as the text memo does. Walks
that ran out of work are not cached. Every link input is still walked.
This commit is contained in:
Vito Sansevero
2026-10-19 15:00:33 -07:00
parent 63e389ff14
commit 57d39374d5
3 changed files with 169 additions and 3 deletions
+6 -2
View File
@@ -253,8 +253,12 @@ single stage whatever the origin (VAEEncode, EmptyLatentBatch, EmptyFlux2LatentI
EmptyHunyuanLatentVideo, SetLatentNoiseMask, InpaintModelConditioning or
WanImageToVideo slot 2). An earlier sampler (img2img from a decode, hires fix) stays
`unresolved` "Multistage sampler ancestry is ambiguous" until stage texts are
compared; an unlisted node with sampler-shaped inputs is `unsupported-node` naming
it; running out of budget is `work-limit` or `depth-limit`, never single stage.
compared; an unlisted node with sampler-shaped inputs (positive, negative, guider,
latent_image, a pipe, video embeds or custom-sampler parts) is `unsupported-node`
naming it, here and in a detailer's image ancestry; a latent that is not a link or
reaches its own sampler is `malformed`; running out of budget is `work-limit` or
`depth-limit`, never single stage. Each sampler's walk runs once per resolution;
selector branch runs share it and other candidates are charged its visit cost.
Text encoders: `single` encoders (the Qwen image encoders, `TextEncodeQwenImage21`
slot 0 `prompt` and slot 1 `negative_prompt`, `KikoTextEncode`) read one string
input unchanged. `parts-equal` encoders (CLIPTextEncodeSDXL, Flux, SD3) resolve when
+127
View File
@@ -2870,6 +2870,133 @@ class TestBreadthFirstWalkWork(unittest.TestCase):
self.assertLessEqual(queued, count + 1)
def _wan_selector_graph(width: int, selectors: int, latent_origin: bool) -> dict:
"""Review I-3: WanImageToVideo over ``width``-input joins and LoRAs, with
``selectors`` diverging runtime selectors on the positive side."""
source = _latent_from(None)
source["50"] = {"class_type": "LoadImage", "inputs": {"image": "a.png"}}
clip = ["4", 1]
for index in range(5):
model = ["4", 0] if index == 0 else [f"lo{index - 1}", 0]
source[f"lo{index}"] = {
"class_type": "LoraLoader",
"inputs": {"model": model, "clip": clip, "lora_name": f"l{index}"},
}
clip = [f"lo{index}", 1]
ends = []
for prefix, text in (("P", "pos"), ("N", "neg")):
parts = {}
for index in range(width):
source[f"{prefix}l{index}"] = _multiline(f"{text} {index}", False)
parts[f"string_{index + 1}"] = [f"{prefix}l{index}", 0]
source[f"{prefix}j"] = _join(width, " ", **parts)
source[f"{prefix}e"] = {
"class_type": "CLIPTextEncode",
"inputs": {"text": [f"{prefix}j", 0], "clip": clip},
}
ends.append(f"{prefix}e")
previous = ends[0]
for index in range(selectors):
source[f"alt{index}"] = _encoded(f"alternative {index}")
source[f"ie{index}"] = {
"class_type": "ImageExists",
"inputs": {"image": ["50", 0]},
}
source[f"sel{index}"] = _if_else(
[f"ie{index}", 1], [previous, 0], [f"alt{index}", 0]
)
previous = f"sel{index}"
source["5"] = {
"class_type": "WanImageToVideo",
"inputs": {
"positive": [previous, 0],
"negative": [ends[1], 0],
"vae": ["4", 2],
"start_image": ["50", 0],
"width": 832,
"height": 480,
},
}
source["3"]["inputs"].update(positive=["5", 0], negative=["5", 1])
if latent_origin:
source["3"]["inputs"]["latent_image"] = ["5", 2]
else:
source["5x"] = {"class_type": "EmptyHunyuanLatentVideo", "inputs": {}}
source["3"]["inputs"]["latent_image"] = ["5x", 0]
return source
class TestLatentWalkReuse(unittest.TestCase):
"""Review I-3: one latent walk per sampler per resolution, reuse charged."""
def counted(self, source: dict) -> tuple:
walks, visits = [], []
walk, spend = resolver._GraphResolver.latent_stages, resolver._Budget.spend
def counted_walk(self, *args):
walks.append(args[0])
return walk(self, *args)
def counted_spend(self):
visits.append(1)
return spend(self)
with (
mock.patch.object(resolver._GraphResolver, "latent_stages", counted_walk),
mock.patch.object(resolver._Budget, "spend", counted_spend),
):
result = _inspect(source)
return result, len(walks), len(visits)
def test_selector_runs_reuse_one_latent_walk(self):
result, walks, visits = self.counted(_wan_selector_graph(100, 3, True))
self.assertEqual(result["state"], "ambiguous")
self.assertEqual(len(result["candidates"]), 8)
self.assertEqual(walks, 1)
separate, _, baseline = self.counted(_wan_selector_graph(100, 3, False))
self.assertEqual(_outcomes(separate).keys(), _outcomes(result).keys())
# One walk over the conditioning subgraph on top of the text tracing.
self.assertLess(visits, baseline + 300)
def test_reused_walk_cost_is_charged_to_each_candidate(self):
# Two samplers share one latent origin: the second candidate is charged
# the walk's cost, so its outcome does not depend on tracing order.
source = _latent_from(
{
"class_type": "VAEEncode",
"inputs": {"pixels": ["50", 0], "vae": ["4", 2]},
},
{"50": {"class_type": "LoadImage", "inputs": {"image": "a.png"}}},
)
source["41"] = {"class_type": "KSampler", "inputs": dict(source["3"]["inputs"])}
source["42"] = {
"class_type": "VAEDecode",
"inputs": {"samples": ["41", 0], "vae": ["4", 2]},
}
source["43"] = {
"class_type": "SaveImage",
"inputs": {"filename_prefix": "b", "images": ["42", 0]},
}
# Both candidates share the latent walk's cached cost.
full = _inspect(source)
self.assertEqual(
[item["state"] for item in full["candidates"]], ["resolved", "resolved"]
)
for budget in range(1, 12):
with self.subTest(max_visits=budget):
both = _inspect(source, max_visits=budget)
states = [
(item["state"], item["reason_code"]) for item in both["candidates"]
]
alone = [
_inspect(source, max_visits=budget, output_node_id=anchor)
for anchor in ("9", "43")
]
self.assertEqual(
states, [(item["state"], item["reason_code"]) for item in alone]
)
REPO = Path(__file__).resolve().parents[1]
SEMANTICS_UNAVAILABLE = "Inspection node semantics are unavailable"
+36 -1
View File
@@ -337,6 +337,12 @@ class _GraphResolver:
# one is "tainted" and never memoized, since it depends on the choice.
self.choices: dict = {}
self.tainted = False
# Latent-walk outcome per sampler (inspection): ``(error or None, visit
# cost)``. It never depends on selector choices, so one walk serves every
# branch run; ``latent_charged`` holds the samplers already paid for by
# the current candidate.
self.latent_walks: dict = {}
self.latent_charged: set = set()
# ``semantics`` (inspection) names the samplers and guiders it follows.
self.semantics = semantics
if semantics is None:
@@ -928,6 +934,7 @@ class _GraphResolver:
def pair(self, sampler_id: str) -> dict:
self.budget.reset(self.per_candidate_visits)
self.latent_charged = set()
if not self.legacy and self.budget.total <= 0:
return _result(
"unresolved",
@@ -1174,7 +1181,7 @@ class _GraphResolver:
if self.semantics is None:
self.latent_source(inputs[latent], set(), 0)
else:
self.latent_stages(sampler_id, inputs[latent])
self.latent_checked(sampler_id, inputs[latent])
guider_id, depth = None, 0
conditioning = entry["conditioning"]
absent_reason = "Negative input is absent"
@@ -1234,6 +1241,34 @@ class _GraphResolver:
return
raise self.unsupported("Unsupported latent ancestry", node_id)
def latent_checked(self, sampler_id: str, value: Any) -> None:
"""``latent_stages`` once per sampler per resolution.
A candidate reusing another candidate's walk is charged its visit cost
(as the text memo does), so outcomes stay independent of order; branch
runs of one candidate share its allowance and pay once. A walk that ran
out of work is never reused, since it depends on the remaining budget.
"""
if sampler_id in self.latent_walks:
error, cost = self.latent_walks[sampler_id]
if sampler_id not in self.latent_charged:
self.budget.charge(cost)
self.latent_charged.add(sampler_id)
else:
before = self.budget.allowance
try:
self.latent_stages(sampler_id, value)
error = None
except _Unresolved as raised:
if raised.code == "work-limit":
raise
error = raised
self.latent_walks[sampler_id] = (error, before - self.budget.allowance)
self.latent_charged.add(sampler_id)
if error is not None:
node = dict(error.node) if error.node is not None else None
raise _Unresolved(str(error), error.code, node)
def latent_stages(self, origin: str, value: Any) -> None:
"""Semantics table: is there an earlier sampler in the latent ancestry?