diff --git a/TESTING.md b/TESTING.md index ec7cfe9..3c74eba 100644 --- a/TESTING.md +++ b/TESTING.md @@ -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 diff --git a/tests/test_prompt_pair_resolver.py b/tests/test_prompt_pair_resolver.py index d37d90a..d24fdb0 100644 --- a/tests/test_prompt_pair_resolver.py +++ b/tests/test_prompt_pair_resolver.py @@ -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" diff --git a/utils/prompt_pair_resolver.py b/utils/prompt_pair_resolver.py index dbb0ff9..05c5047 100644 --- a/utils/prompt_pair_resolver.py +++ b/utils/prompt_pair_resolver.py @@ -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?