fix(inspect): treat unlisted pipe and embed samplers as possible stages

The latent and detailer ancestry walks only flagged unlisted nodes with a
linked latent_image, guider, positive or negative input, so easy-use pipe
samplers, Impact pipe detailers and embed-driven video samplers were walked
through and the final sampler's prompt resolved silently. A separate
stage-shaped input set (adding pipes, embeds and custom-sampler parts)
now makes such nodes unsupported-node, named.
This commit is contained in:
Vito Sansevero
2026-10-19 15:00:33 -07:00
parent 7504bcd20c
commit 63e389ff14
2 changed files with 173 additions and 4 deletions
+151 -1
View File
@@ -2571,7 +2571,133 @@ class TestLatentAncestry(unittest.TestCase):
source["3"]["inputs"]["latent_image"] = None
self.assertEqual(_inspect(source)["reason_code"], "malformed")
def test_unclassified_sampler_shaped_latent_start_is_unsupported_and_named(self):
def test_unlisted_pipe_and_custom_samplers_in_the_ancestry_are_unsupported(self):
# Review I-1: stages whose inputs are pipes, embeds or sampler parts
# (easy-use, Impact pipes, video and custom samplers) must never be
# walked through to a silent single-stage pick of the final prompt.
easy = _latent_from(None)
easy["3"]["inputs"]["positive"] = ["62", 0]
easy.update(
{
"62": parity._enc(OTHER),
"20": {
"class_type": "easy fullLoader",
"inputs": {"positive": "first pass", "negative": "bad"},
},
"21": {"class_type": "easy preSampling", "inputs": {"pipe": ["20", 0]}},
"22": {"class_type": "easy kSampler", "inputs": {"pipe": ["21", 0]}},
"23": {
"class_type": "VAEEncode",
"inputs": {"pixels": ["22", 1], "vae": ["4", 2]},
},
}
)
easy["3"]["inputs"]["latent_image"] = ["23", 0]
pipes = _latent_from(None)
pipes.update(
{
"50": {"class_type": "LoadImage", "inputs": {"image": "a.png"}},
"20": {
"class_type": "easy fullLoader",
"inputs": {"positive": "segs prompt", "negative": "n"},
},
"24": {
"class_type": "easy pipeToBasicPipe",
"inputs": {"pipe": ["20", 0]},
},
"26": {
"class_type": "BasicPipeToDetailerPipe",
"inputs": {"basic_pipe": ["24", 0]},
},
"25": {
"class_type": "FaceDetailerPipe",
"inputs": {"image": ["50", 0], "detailer_pipe": ["26", 0]},
},
"23": {
"class_type": "VAEEncode",
"inputs": {"pixels": ["25", 0], "vae": ["4", 2]},
},
}
)
pipes["3"]["inputs"]["latent_image"] = ["23", 0]
cases = {"easy": (easy, "22", "easy kSampler")}
# Breadth-first, the first unlisted pipe consumer met is named.
cases["pipes"] = (pipes, "25", "FaceDetailerPipe")
for label, embeds in (
("text_embeds", "WanVideoSampler"),
("hyvid_embeds", "HyVideoSampler"),
("sigmas", "MysteryCustomSampler"),
("noise", "MysteryNoiseSampler"),
("sampler", "MysterySamplerStage"),
):
source = _latent_from(None)
source.update(
{
"30": {"class_type": "MysteryEmbeds", "inputs": {}},
"31": {"class_type": embeds, "inputs": {label: ["30", 0]}},
"32": {
"class_type": "VAEDecode",
"inputs": {"samples": ["31", 0], "vae": ["4", 2]},
},
"23": {
"class_type": "VAEEncode",
"inputs": {"pixels": ["32", 0], "vae": ["4", 2]},
},
}
)
source["3"]["inputs"]["latent_image"] = ["23", 0]
cases[label] = (source, "31", embeds)
for label, (source, node_id, class_type) in cases.items():
with self.subTest(case=label):
result = _inspect(source)
self.assertEqual(result["state"], "unresolved")
self.assertEqual(result["reason_code"], "unsupported-node")
self.assertEqual(result["reason"], "Unsupported latent ancestry")
self.assertEqual(
result["node"], {"id": node_id, "class_type": class_type}
)
self.assertIsNone(result["positive"])
# A LoRA in a walked CLIP chain is not a stage (``model`` is not one).
source = _latent_from(
{
"class_type": "InpaintModelConditioning",
"inputs": {
"positive": ["6", 0],
"negative": ["7", 0],
"vae": ["4", 2],
"pixels": ["50", 0],
"mask": ["50", 1],
},
},
{
"50": {"class_type": "LoadImage", "inputs": {"image": "a.png"}},
"40": {
"class_type": "LoraLoader",
"inputs": {"model": ["4", 0], "clip": ["4", 1]},
},
},
)
source["6"]["inputs"]["clip"] = ["40", 1]
source["3"]["inputs"].update(
positive=["5", 0], negative=["5", 1], latent_image=["5", 2]
)
self.assertEqual(_inspect(source)["state"], "resolved")
def test_unlisted_stage_in_a_detailer_image_ancestry_is_unsupported(self):
# Review M-1: the detailer's upstream-stage walk uses the same rule.
source = _detailed(same_prompt=True)
source.update(
{
"20": {"class_type": "easy fullLoader", "inputs": {"positive": "x"}},
"22": {"class_type": "easy kSampler", "inputs": {"pipe": ["20", 0]}},
}
)
source["50"]["inputs"]["image"] = ["22", 1]
result = _inspect(source, output_node_id="9")
self.assertEqual(result["reason_code"], "unsupported-node")
self.assertEqual(result["node"], {"id": "22", "class_type": "easy kSampler"})
def test_unclassified_sampler_shaped_latent_node_is_unsupported_and_named(self):
# An unlisted node with sampler inputs may itself be an earlier stage:
# never call the chain single-stage across it.
source = _latent_from(
@@ -2593,6 +2719,30 @@ class TestLatentAncestry(unittest.TestCase):
result["node"], {"id": "5", "class_type": "MysteryLatentSampler"}
)
self.assertIsNone(result["positive"])
# The rule holds at any depth (review M-3): behind a VAEEncode.
source = _latent_from(
{
"class_type": "VAEEncode",
"inputs": {"pixels": ["60", 0], "vae": ["4", 2]},
},
{
"50": {"class_type": "LoadImage", "inputs": {"image": "a.png"}},
"60": {
"class_type": "UltimateSDUpscale",
"inputs": {
"image": ["50", 0],
"model": ["4", 0],
"positive": ["6", 0],
"negative": ["7", 0],
},
},
},
)
result = _inspect(source, output_node_id="9")
self.assertEqual(result["reason_code"], "unsupported-node")
self.assertEqual(
result["node"], {"id": "60", "class_type": "UltimateSDUpscale"}
)
def test_latent_walk_budget_exhaustion_is_work_limit(self):
# 2,100 distinct images in the latent ancestry: more than one
+22 -3
View File
@@ -157,6 +157,19 @@ _UNVERIFIABLE_CODES = frozenset({"unsupported-node", "runtime-value"})
# JoinStringMulti's own ``inputcount`` range; anything else never executed.
_MAX_JOIN_INPUTS = 1000
_SAMPLER_LIKE_INPUTS = ("latent_image", "guider", "positive", "negative")
# Inputs that make an unlisted node a possible sampler stage in an ancestry walk:
# pipes (easy-use, Impact), video embeds and custom-sampler parts carry a prompt
# without a ``positive`` link. ``model`` is not one (LoRA loaders are walked).
_STAGE_SHAPED_INPUTS = _SAMPLER_LIKE_INPUTS + (
"pipe",
"basic_pipe",
"detailer_pipe",
"sampler",
"sigmas",
"noise",
"text_embeds",
"hyvid_embeds",
)
_NO_SAMPLER_NAMES = 3
_CLASS_NAME_LENGTH = 64
@@ -1009,6 +1022,12 @@ class _GraphResolver:
queued.add(key)
queue.append((key, depth))
def stage_shaped(self, name: str, inputs: dict) -> bool:
"""An unlisted node that may itself be a sampler stage."""
return name not in self.semantics.all_classes and any(
_link(inputs.get(key)) for key in _STAGE_SHAPED_INPUTS
)
def upstream_stages(self, origin: str, value: Any) -> list:
"""Samplers reached from a detailer's image input, in breadth-first order.
@@ -1030,6 +1049,8 @@ class _GraphResolver:
if name in self.stage_classes:
found.append(node_id)
continue
if self.stage_shaped(name, inputs):
raise self.unsupported("Unsupported sampler stage ancestry", node_id)
self.enqueue(queue, queued, inputs, depth + 1)
return found
@@ -1238,9 +1259,7 @@ class _GraphResolver:
# ``multistage-differs`` is reserved for when stage texts are
# compared (PR3); until then no stage's prompt is chosen.
raise _Unresolved("Multistage sampler ancestry is ambiguous")
if name not in self.semantics.all_classes and any(
_link(inputs.get(key)) for key in _SAMPLER_LIKE_INPUTS
):
if self.stage_shaped(name, inputs):
raise self.unsupported("Unsupported latent ancestry", node_id)
self.enqueue(queue, queued, inputs, depth + 1)