fix(inspect): treat unlisted model-driven samplers as possible stages

Unlisted samplers that link a model with conditioning or a latent under
other input names (XlabsSampler, generic video samplers) were still walked
through, so the final sampler's prompt resolved silently. An unlisted node
with a linked model plus conditioning, neg_conditioning, samples or latent
is now unsupported-node, named; model patches such as LoraLoader and
ModelSampling nodes are not.
This commit is contained in:
Vito Sansevero
2026-10-19 15:00:33 -07:00
parent 57d39374d5
commit f0c25b9ef3
3 changed files with 109 additions and 3 deletions
+2 -1
View File
@@ -254,7 +254,8 @@ 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 (positive, negative, guider,
latent_image, a pipe, video embeds or custom-sampler parts) is `unsupported-node`
latent_image, a pipe, video embeds, custom-sampler parts, or a `model` link together
with conditioning, neg_conditioning, samples or latent) 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;
+97
View File
@@ -2683,6 +2683,103 @@ class TestLatentAncestry(unittest.TestCase):
)
self.assertEqual(_inspect(source)["state"], "resolved")
def test_unlisted_model_driven_samplers_in_the_ancestry_are_unsupported(self):
# Re-review I-1b: a sampler linking ``model`` with conditioning or a
# latent under other names (XlabsSampler, generic video samplers).
xlabs = _latent_from(None)
xlabs["3"]["inputs"]["positive"] = ["62", 0]
xlabs.update(
{
"62": parity._enc(OTHER),
"30": {
"class_type": "CLIPTextEncodeFlux",
"inputs": {
"clip": ["4", 1],
"t5xxl": "first stage",
"clip_l": "first stage",
"guidance": 3.5,
},
},
"31": parity._enc("neg"),
"22": {
"class_type": "XlabsSampler",
"inputs": {
"model": ["4", 0],
"conditioning": ["30", 0],
"neg_conditioning": ["31", 0],
"noise_seed": 1,
},
},
"28": {
"class_type": "VAEDecode",
"inputs": {"samples": ["22", 0], "vae": ["4", 2]},
},
"23": {
"class_type": "VAEEncode",
"inputs": {"pixels": ["28", 0], "vae": ["4", 2]},
},
}
)
xlabs["3"]["inputs"]["latent_image"] = ["23", 0]
video = _latent_from(None)
video.update(
{
"40": {"class_type": "EmptyLatentImage", "inputs": {}},
"22": {
"class_type": "SomeVideoSampler",
"inputs": {"model": ["4", 0], "samples": ["40", 0], "seed": 1},
},
"23": {
"class_type": "LatentUpscale",
"inputs": {"samples": ["22", 0], "width": 768, "height": 768},
},
}
)
video["3"]["inputs"]["latent_image"] = ["23", 0]
reference = _latent_from(None)
reference.update(
{
"22": {
"class_type": "MysteryLatentStage",
"inputs": {"model": ["4", 0], "latent": ["5", 0]},
},
}
)
reference["3"]["inputs"]["latent_image"] = ["22", 0]
cases = {
"XlabsSampler": (xlabs, "22"),
"SomeVideoSampler": (video, "22"),
"MysteryLatentStage": (reference, "22"),
}
for class_type, (source, node_id) in cases.items():
with self.subTest(case=class_type):
result = _inspect(source)
self.assertEqual(result["state"], "unresolved")
self.assertEqual(result["reason_code"], "unsupported-node")
self.assertEqual(
result["node"], {"id": node_id, "class_type": class_type}
)
self.assertIsNone(result["positive"])
# ``model`` alone (ModelSampling*) or with ``clip`` (LoraLoader) is a
# model patch, never a stage.
patched = _latent_from(
{
"class_type": "MysteryLatentFromModel",
"inputs": {"model": ["41", 0], "vae": ["4", 2]},
},
{
"40": {
"class_type": "LoraLoader",
"inputs": {"model": ["4", 0], "clip": ["4", 1]},
},
"41": {
"class_type": "ModelSamplingFlux",
"inputs": {"model": ["40", 0], "max_shift": 1.15},
},
},
)
self.assertEqual(_inspect(patched)["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)
+10 -2
View File
@@ -170,6 +170,10 @@ _STAGE_SHAPED_INPUTS = _SAMPLER_LIKE_INPUTS + (
"text_embeds",
"hyvid_embeds",
)
# With a linked ``model``, any of these makes an unlisted node a possible stage
# (XlabsSampler's ``conditioning``, video samplers' ``samples``). ``model`` alone
# (ModelSampling*) or with ``clip`` (LoraLoader) is a model patch.
_MODEL_STAGE_INPUTS = ("conditioning", "neg_conditioning", "samples", "latent")
_NO_SAMPLER_NAMES = 3
_CLASS_NAME_LENGTH = 64
@@ -1031,8 +1035,12 @@ class _GraphResolver:
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
if name in self.semantics.all_classes:
return False
if any(_link(inputs.get(key)) for key in _STAGE_SHAPED_INPUTS):
return True
return _link(inputs.get("model")) and any(
_link(inputs.get(key)) for key in _MODEL_STAGE_INPUTS
)
def upstream_stages(self, origin: str, value: Any) -> list: