From f0c25b9ef3b63b7145ca250804238fa8e7abb946 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Mon, 19 Oct 2026 11:41:26 -0700 Subject: [PATCH] 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. --- TESTING.md | 3 +- tests/test_prompt_pair_resolver.py | 97 ++++++++++++++++++++++++++++++ utils/prompt_pair_resolver.py | 12 +++- 3 files changed, 109 insertions(+), 3 deletions(-) diff --git a/TESTING.md b/TESTING.md index 3c74eba..d249e9d 100644 --- a/TESTING.md +++ b/TESTING.md @@ -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; diff --git a/tests/test_prompt_pair_resolver.py b/tests/test_prompt_pair_resolver.py index d24fdb0..6f5fd89 100644 --- a/tests/test_prompt_pair_resolver.py +++ b/tests/test_prompt_pair_resolver.py @@ -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) diff --git a/utils/prompt_pair_resolver.py b/utils/prompt_pair_resolver.py index 05c5047..a9c12b1 100644 --- a/utils/prompt_pair_resolver.py +++ b/utils/prompt_pair_resolver.py @@ -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: