diff --git a/TESTING.md b/TESTING.md index 1372d92..a9489d1 100644 --- a/TESTING.md +++ b/TESTING.md @@ -232,7 +232,16 @@ semantics table `web/js/inspection-semantics.json` (at most 1 MiB and eight nest levels, loaded on first use). With it, `SamplerCustomAdvanced` follows its `guider` link (through Reroutes) to `CFGGuider` (positive and negative) or `BasicGuider` (positive only; the negative is `absent`, "Guider has no negative input"). Any -other guider, such as `DualCFGGuider`, is `unsupported-node`. If the table cannot +other guider, such as `DualCFGGuider`, is `unsupported-node`. Conditioning is +traced through the table's pass-throughs (FluxGuidance, ReferenceLatent, the +ConditioningSet* nodes, ControlNet apply nodes, InpaintModelConditioning and +WanImageToVideo by output slot). `ConditioningZeroOut` is an explicit empty text +with source `zeroed-conditioning`. `easy ifElse` with a literal boolean follows that +branch only; with a linked boolean both branches are traced, equal texts resolve and +different texts make the sampler `ambiguous` with one candidate per choice (at most +32, else `work-limit`). ConditioningCombine/Concat/Average resolve only when every +part has the same text, otherwise `unresolved` with `composite` naming the node. +Results that depend on a forced choice are never memoized. If the table cannot be loaded, graph inspection is `unresolved` ("Inspection node semantics are unavailable") and ingestion is unaffected, because it never imports the table. diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index 25e0686..cab86c8 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -15,7 +15,7 @@ Browser tests use the committed file; FFmpeg is not required to run them. # Inspection parity fixtures -`inspection_parity/` holds small synthetic PNGs (`f01b.png` ... `f17.png`, each +`inspection_parity/` holds small synthetic PNGs (`f01b.png` ... `f23.png`, each under 32 KiB) and `vectors.json`, used by the #240 inspection parity tests. Every graph, prompt text, model name and filename prefix in them is original synthetic content written for this repository. Nothing comes from user output. They are diff --git a/tests/fixtures/inspection_parity/f20.png b/tests/fixtures/inspection_parity/f20.png new file mode 100644 index 0000000..81babea Binary files /dev/null and b/tests/fixtures/inspection_parity/f20.png differ diff --git a/tests/fixtures/inspection_parity/f23.png b/tests/fixtures/inspection_parity/f23.png new file mode 100644 index 0000000..f15f1cc Binary files /dev/null and b/tests/fixtures/inspection_parity/f23.png differ diff --git a/tests/fixtures/inspection_parity/vectors.json b/tests/fixtures/inspection_parity/vectors.json index 1f86062..1680b06 100644 --- a/tests/fixtures/inspection_parity/vectors.json +++ b/tests/fixtures/inspection_parity/vectors.json @@ -342,12 +342,12 @@ "anchor": null, "expect": { "metadata_status": "available", - "state": "unresolved", - "reason": "Unsupported node semantics or output slot", - "reason_code": "unsupported-node", + "state": "resolved", + "reason": "", + "reason_code": null, "selection": null, - "positive": null, - "negative": null, + "positive": "a red fox in snow, detailed fur", + "negative": "blurry, lowres", "candidate_count": 0, "facts": { "seed": { @@ -385,8 +385,8 @@ "expect": { "metadata_status": "available", "state": "unresolved", - "reason": "Unsupported node semantics or output slot", - "reason_code": "unsupported-node", + "reason": "Combined conditioning has different texts", + "reason_code": "composite", "selection": null, "positive": null, "negative": null, @@ -426,12 +426,12 @@ "anchor": null, "expect": { "metadata_status": "available", - "state": "unresolved", - "reason": "Unsupported node semantics or output slot", - "reason_code": "unsupported-node", + "state": "resolved", + "reason": "", + "reason_code": null, "selection": null, - "positive": null, - "negative": null, + "positive": "a red fox in snow, detailed fur", + "negative": "blurry, lowres", "candidate_count": 0, "facts": { "seed": { @@ -888,12 +888,12 @@ "anchor": null, "expect": { "metadata_status": "available", - "state": "unresolved", - "reason": "Unsupported node semantics or output slot", - "reason_code": "unsupported-node", + "state": "absent", + "reason": "Guider has no negative input", + "reason_code": null, "selection": null, - "positive": null, - "negative": null, + "positive": "a red fox in snow, detailed fur", + "negative": "", "candidate_count": 0, "facts": { "seed": { @@ -923,6 +923,90 @@ } } }, + { + "name": "f20-inspection", + "fixture": "f20", + "profile": "inspection", + "anchor": null, + "expect": { + "metadata_status": "available", + "state": "resolved", + "reason": "", + "reason_code": null, + "selection": null, + "positive": "a red fox in snow, detailed fur", + "negative": "blurry, lowres", + "candidate_count": 0, + "facts": { + "seed": { + "status": "known", + "value": "42" + }, + "steps": { + "status": "known", + "value": "25" + }, + "cfg": { + "status": "known", + "value": "6.5" + }, + "sampler": { + "status": "known", + "value": "euler" + }, + "scheduler": { + "status": "known", + "value": "normal" + }, + "model": { + "status": "known", + "value": "sdxl_base.safetensors" + } + } + } + }, + { + "name": "f23-inspection", + "fixture": "f23", + "profile": "inspection", + "anchor": null, + "expect": { + "metadata_status": "available", + "state": "resolved", + "reason": "", + "reason_code": null, + "selection": null, + "positive": "a red fox in snow, detailed fur", + "negative": "", + "candidate_count": 0, + "facts": { + "seed": { + "status": "known", + "value": "42" + }, + "steps": { + "status": "known", + "value": "25" + }, + "cfg": { + "status": "known", + "value": "6.5" + }, + "sampler": { + "status": "known", + "value": "euler" + }, + "scheduler": { + "status": "known", + "value": "normal" + }, + "model": { + "status": "known", + "value": "sdxl_base.safetensors" + } + } + } + }, { "name": "f01-default", "fixture": "f01", @@ -1296,5 +1380,39 @@ "candidate_count": 0, "facts": null } + }, + { + "name": "f20-default", + "fixture": "f20", + "profile": "default", + "anchor": null, + "expect": { + "metadata_status": "available", + "state": "unresolved", + "reason": "Unsupported node semantics or output slot", + "reason_code": null, + "selection": null, + "positive": null, + "negative": null, + "candidate_count": 0, + "facts": null + } + }, + { + "name": "f23-default", + "fixture": "f23", + "profile": "default", + "anchor": null, + "expect": { + "metadata_status": "available", + "state": "unresolved", + "reason": "Unsupported node semantics or output slot", + "reason_code": null, + "selection": null, + "positive": null, + "negative": null, + "candidate_count": 0, + "facts": null + } } ] diff --git a/tests/inspection_parity.py b/tests/inspection_parity.py index dc33cfc..63933f9 100644 --- a/tests/inspection_parity.py +++ b/tests/inspection_parity.py @@ -2,7 +2,7 @@ Every graph here is original synthetic content (CC0): neutral texts, generic model file names and invented filename prefixes. Nothing is copied from user outputs. -The shapes reproduce the parity audit's fixture numbering (``f01``..``f17``). +The shapes reproduce the parity audit's fixture numbering (``f01``..``f23``). ``build(name)`` returns ``{"prompt", "workflow", "extra_chunks"}``. ``write_png`` writes the two records the way core ``SaveImage`` does (Pillow ``PngInfo.add_text``, @@ -66,6 +66,8 @@ COMMITTED = ( "f15", "f16", "f17", + "f20", + "f23", ) # Built in a temporary directory at test time (too large to commit). TEST_TIME = ("f01", "f04", "f05") @@ -691,6 +693,22 @@ def _f17(): return _basic(graph, _ui(graph)) +def _f20(): + """``easy ifElse`` with a literal boolean picks the positive branch.""" + select = { + "class_type": "easy ifElse", + "inputs": {"boolean": True, "on_true": ["10", 0], "on_false": ["11", 0]}, + } + extra = {"10": _enc(POS), "11": _enc("a grey wolf in fog")} + return _basic(_ksampler_graph(select, _enc(NEG), extra=extra)) + + +def _f23(): + """The negative is ``ConditioningZeroOut`` of the positive encoder.""" + zero = {"class_type": "ConditioningZeroOut", "inputs": {"conditioning": ["6", 0]}} + return _basic(_ksampler_graph(_enc(POS), zero)) + + _BUILDERS = { "f01": _f01, "f01b": _f01b, @@ -714,6 +732,8 @@ _BUILDERS = { "f15": _f15, "f16": _f16, "f17": _f17, + "f20": _f20, + "f23": _f23, } diff --git a/tests/js/legacy-metadata-characterization.test.js b/tests/js/legacy-metadata-characterization.test.js index cb1ed37..ba844a7 100644 --- a/tests/js/legacy-metadata-characterization.test.js +++ b/tests/js/legacy-metadata-characterization.test.js @@ -3,7 +3,8 @@ // (web/metadata.html, web/js/gallery.js) show today on the synthetic inspection // parity fixtures, so the #240 workspace work cannot change them by accident. // Some answers are known to be wrong (f01b shows the preview-only detailer text -// as both roles; f16 shows the last of two conflicting prompt chunks). They are +// as both roles; f16 shows the last of two conflicting prompt chunks; f23 shows the +// positive text as the zeroed negative). They are // recorded as-is and intentionally unchanged (spec section 11). const test = require("node:test"); const assert = require("node:assert/strict"); @@ -56,6 +57,8 @@ const EXPECTED = { f15: { positivePrompt: "a red fox in snow, detailed fur", negativePrompt: "blurry, lowres", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 }, f16: { positivePrompt: "a grey wolf in fog", negativePrompt: "blurry, lowres", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 }, f17: { positivePrompt: "No prompt found", negativePrompt: "No negative prompt found", checkpoint: "flux1-dev.safetensors", steps: "Unknown", cfgScale: "Unknown", sampler: "euler", seed: "Unknown" }, + f20: { positivePrompt: "No prompt found", negativePrompt: "blurry, lowres", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 }, + f23: { positivePrompt: "a red fox in snow, detailed fur", negativePrompt: "a red fox in snow, detailed fur", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 }, }; test("legacy extractGenerationParams characterization covers every committed fixture", () => { diff --git a/tests/js/prompt-blocks.test.js b/tests/js/prompt-blocks.test.js index 9805a61..dea91bd 100644 --- a/tests/js/prompt-blocks.test.js +++ b/tests/js/prompt-blocks.test.js @@ -70,3 +70,14 @@ test('frozen fields disclose their exact source and keep null, empty and whitesp container.click(f.walk(container,node=>node.dataset.sendRole==='pair')[0]);assert.equal(sent.length,2); }finally{scope.dispose();f.restore();} }); + +test('a zeroed conditioning negative shows as an explicit empty negative with its own source',()=>{ + const f=dom(),scope=createScope(),container=new f.Element('div'),sent=[]; + try{ + module.mountPromptBlocks(container,{parentScope:scope,sender:{open:roles=>sent.push(roles)},prompts:{positive:{status:'resolved',text:'positive',source:'graph-inferred'},negative:{status:'resolved',text:'',source:'zeroed-conditioning'}}}); + assert.equal(f.walk(container,node=>node.textContent==='Zeroed conditioning (no negative text)').length,1); + assert.equal(f.walk(container,node=>node.textContent==='Empty negative prompt').length,1); + container.click(f.walk(container,node=>node.dataset.sendRole==='pair')[0]); + assert.deepEqual(sent,[{positive:'positive',negative:''}]); + }finally{scope.dispose();f.restore();} +}); diff --git a/tests/test_prompt_pair_resolver.py b/tests/test_prompt_pair_resolver.py index fc466ab..6893f78 100644 --- a/tests/test_prompt_pair_resolver.py +++ b/tests/test_prompt_pair_resolver.py @@ -1249,6 +1249,340 @@ class TestGuiderSamplers(unittest.TestCase): ) +WOLF = "a grey wolf in fog" + + +def _encoded(text: str) -> dict: + return {"class_type": "CLIPTextEncode", "inputs": {"text": text, "clip": ["4", 1]}} + + +def _with_positive(node: dict, extra: dict = None) -> dict: + """``f02``'s KSampler graph whose positive input reads ``node`` (as node 6).""" + source = parity.build("f02")["prompt"] + source["6"] = node + source.update(extra or {}) + return source + + +def _if_else(condition, on_true, on_false) -> dict: + return { + "class_type": "easy ifElse", + "inputs": {"boolean": condition, "on_true": on_true, "on_false": on_false}, + } + + +def _image_exists() -> dict: + return {"class_type": "ImageExists", "inputs": {"image": ["13", 0]}} + + +def _sides(result: dict) -> tuple: + return tuple( + result[role]["text"] if isinstance(result[role], dict) else None + for role in ("positive", "negative") + ) + + +class TestConditioningSemantics(unittest.TestCase): + """Pass-throughs, zeroed conditioning, selectors and composites (inspection).""" + + def test_flux_guidance_passthrough_resolves(self): + result = _inspect(parity.build("f06c")["prompt"]) + self.assertEqual(result["state"], "resolved") + self.assertIsNone(result["reason_code"]) + self.assertEqual(_sides(result), (parity.POS, parity.NEG)) + provenance = result["positive"]["provenance"] + self.assertEqual( + provenance["path"], + [{"node_id": "6", "output_slot": 0}, {"node_id": "10", "output_slot": 0}], + ) + self.assertEqual(provenance["text_source"], "graph-inferred") + # f17: BasicGuider <- FluxGuidance <- CLIPTextEncode, negative absent. + guided = _inspect(parity.build("f17")["prompt"]) + self.assertEqual(guided["state"], "absent") + self.assertEqual(guided["reason"], "Guider has no negative input") + self.assertEqual(guided["positive"]["text"], parity.POS) + + def test_controlnet_apply_advanced_maps_slots(self): + source = parity.build("f06e")["prompt"] + result = _inspect(source) + self.assertEqual(result["state"], "resolved") + self.assertEqual(_sides(result), (parity.POS, parity.NEG)) + self.assertEqual( + result["negative"]["provenance"]["path"], + [{"node_id": "7", "output_slot": 1}, {"node_id": "11", "output_slot": 0}], + ) + # Slot 0 of the negative's node is its positive output, never the negative. + source["3"]["inputs"]["negative"] = ["7", 0] + self.assertEqual(_sides(_inspect(source)), (parity.POS, parity.POS)) + # An output slot the table does not list is unsupported, named. + source["3"]["inputs"]["negative"] = ["7", 2] + result = _inspect(source) + self.assertEqual(result["reason_code"], "unsupported-node") + self.assertEqual( + result["node"], {"id": "7", "class_type": "ControlNetApplyAdvanced"} + ) + + def test_reference_latent_and_set_area_pass_through(self): + chain = [ + ("ReferenceLatent", {"latent": ["5", 0]}), + ("ConditioningSetArea", {"width": 64, "height": 64, "x": 0, "y": 0}), + ("ConditioningSetAreaPercentage", {"width": 1.0, "height": 1.0}), + ("ConditioningSetAreaStrength", {"strength": 1.0}), + ("ConditioningSetMask", {"mask": ["40", 0], "strength": 1.0}), + ("ConditioningSetTimestepRange", {"start": 0.0, "end": 1.0}), + ("ControlNetApply", {"control_net": ["41", 0], "strength": 1.0}), + ] + extra, previous = {"10": _encoded(parity.POS)}, "10" + for index, (name, inputs) in enumerate(chain): + node_id = f"2{index}" + extra[node_id] = { + "class_type": name, + "inputs": {**inputs, "conditioning": [previous, 0]}, + } + previous = node_id + source = _with_positive(extra.pop(previous), extra) + result = _inspect(source) + self.assertEqual(result["state"], "resolved") + self.assertEqual(_sides(result), (parity.POS, parity.NEG)) + path = result["positive"]["provenance"]["path"] + self.assertEqual( + [entry["node_id"] for entry in path], + ["6", "25", "24", "23", "22", "21", "20", "10"], + ) + + def test_inpaint_model_conditioning_slots(self): + inpaint = { + "class_type": "InpaintModelConditioning", + "inputs": { + "positive": ["10", 0], + "negative": ["11", 0], + "vae": ["4", 2], + "pixels": ["13", 0], + "mask": ["14", 0], + "noise_mask": True, + }, + } + extra = {"10": _encoded(parity.POS), "11": _encoded(parity.NEG)} + source = _with_positive(inpaint, extra) + source["3"]["inputs"]["negative"] = ["6", 1] + result = _inspect(source) + self.assertEqual(result["state"], "resolved") + self.assertEqual(_sides(result), (parity.POS, parity.NEG)) + self.assertEqual(result["positive"]["provenance"]["output_slot"], 0) + self.assertEqual(result["negative"]["provenance"]["output_slot"], 1) + + def test_zeroed_negative_is_explicit_empty_with_distinct_source(self): + from utils.prompt_inspector import inspect_prompt_evidence + + source = parity.build("f23")["prompt"] + result = _inspect(source) + self.assertEqual(result["state"], "resolved") + negative = result["negative"] + self.assertEqual((negative["text"], negative["storage_text"]), ("", "")) + self.assertEqual(negative["provenance"]["text_source"], "zeroed-conditioning") + self.assertEqual( + negative["provenance"]["path"], [{"node_id": "7", "output_slot": 0}] + ) + self.assertEqual(result["positive"]["text"], parity.POS) + self.assertEqual( + result["positive"]["provenance"]["text_source"], "graph-inferred" + ) + evidence = inspect_prompt_evidence({"prompt": source})["prompts"] + self.assertEqual( + evidence["negative"], + {"status": "resolved", "text": "", "source": "zeroed-conditioning"}, + ) + # The source survives pass-throughs and reroutes in front of it. + source["30"] = { + "class_type": "FluxGuidance", + "inputs": {"guidance": 3.5, "conditioning": ["7", 0]}, + } + source["31"] = {"class_type": "Reroute", "inputs": {"value": ["30", 0]}} + source["3"]["inputs"]["negative"] = ["31", 0] + negative = _inspect(source)["negative"] + self.assertEqual(negative["text"], "") + self.assertEqual(negative["provenance"]["text_source"], "zeroed-conditioning") + # A zeroed positive is an empty positive, never a resolved prompt. + source["3"]["inputs"]["positive"] = ["7", 0] + result = _inspect(source) + self.assertEqual(result["state"], "unresolved") + self.assertEqual(result["reason"], "Positive prompt is empty") + + def test_if_else_literal_boolean_selects_branch(self): + source = parity.build("f20")["prompt"] + result = _inspect(source) + self.assertEqual(result["state"], "resolved") + self.assertEqual(_sides(result), (parity.POS, parity.NEG)) + self.assertEqual( + [entry["node_id"] for entry in result["positive"]["provenance"]["path"]], + ["6", "10"], + ) + source["6"]["inputs"]["boolean"] = False + self.assertEqual(_inspect(source)["positive"]["text"], WOLF) + # The branch that is not taken is never traced. + source["6"]["inputs"]["on_true"] = ["99", 0] + self.assertEqual(_inspect(source)["state"], "resolved") + for literal in (1, "true", None): + with self.subTest(literal=literal): + source["6"]["inputs"]["boolean"] = literal + result = _inspect(source) + self.assertEqual(result["state"], "unresolved") + self.assertEqual(result["reason_code"], "malformed") + self.assertIsNone(result["positive"]) + + def test_if_else_linked_boolean_converging_branches_resolve(self): + guidance = { + "class_type": "FluxGuidance", + "inputs": {"guidance": 3.5, "conditioning": ["10", 0]}, + } + extra = {"10": _encoded(parity.POS), "11": guidance, "12": _image_exists()} + source = _with_positive(_if_else(["12", 1], ["11", 0], ["10", 0]), extra) + result = _inspect(source) + self.assertEqual(result["state"], "resolved") + self.assertEqual(_sides(result), (parity.POS, parity.NEG)) + self.assertEqual(result["sampler_id"], "3") + self.assertEqual(result["candidates"], []) + # Two encoders with the same text converge as well. + source["11"] = _encoded(parity.POS) + self.assertEqual(_inspect(source)["state"], "resolved") + + def test_if_else_linked_boolean_diverging_branches_are_ambiguous_with_candidates( + self, + ): + extra = { + "10": _encoded(parity.POS), + "11": _encoded(WOLF), + "12": _image_exists(), + } + source = _with_positive(_if_else(["12", 1], ["10", 0], ["11", 0]), extra) + result = _inspect(source) + self.assertEqual(result["state"], "ambiguous") + self.assertEqual(result["sampler_id"], "3") + self.assertIsNone(result["positive"]) + self.assertIsNone(result["negative"]) + candidates = result["candidates"] + self.assertEqual([item["state"] for item in candidates], ["resolved"] * 2) + self.assertEqual( + [_sides(item) for item in candidates], + [(parity.POS, parity.NEG), (WOLF, parity.NEG)], + ) + self.assertEqual( + [ + item["positive"]["provenance"]["path"][-1]["node_id"] + for item in candidates + ], + ["10", "11"], + ) + # One selector read by both roles is one runtime choice, not four pairs. + source["3"]["inputs"]["negative"] = ["6", 0] + candidates = _inspect(source)["candidates"] + self.assertEqual( + [_sides(item) for item in candidates], + [(parity.POS, parity.POS), (WOLF, WOLF)], + ) + # Independent selectors on each role multiply. + source["7"] = _if_else(["12", 1], ["10", 0], ["14", 0]) + source["14"] = _encoded(parity.NEG) + source["3"]["inputs"]["negative"] = ["7", 0] + candidates = _inspect(source)["candidates"] + self.assertEqual(len(candidates), 4) + self.assertEqual(len({_sides(item) for item in candidates}), 4) + + def test_diverging_branch_that_cannot_be_traced_is_never_guessed(self): + extra = { + "10": _encoded(parity.POS), + "11": {"class_type": "FutureEncoder", "inputs": {"text": WOLF}}, + "12": _image_exists(), + } + source = _with_positive(_if_else(["12", 1], ["10", 0], ["11", 0]), extra) + result = _inspect(source) + self.assertEqual(result["state"], "unresolved") + self.assertEqual(result["node"], {"id": "11", "class_type": "FutureEncoder"}) + self.assertIsNone(result["positive"]) + + def test_selector_branches_are_bounded(self): + extra, previous = {"12": _image_exists()}, None + for index in range(6): + extra[f"a{index}"] = _encoded(f"left {index}") + extra[f"b{index}"] = _encoded(f"right {index}") + extra[f"s{index}"] = _if_else(["12", 1], [f"a{index}", 0], [f"b{index}", 0]) + extra[f"c{index}"] = { + "class_type": "ConditioningCombine", + "inputs": { + "conditioning_1": [f"s{index}", 0], + "conditioning_2": [previous or f"s{index}", 0], + }, + } + previous = f"c{index}" + source = _with_positive( + {"class_type": "Reroute", "inputs": {"value": [previous, 0]}}, extra + ) + result = _inspect(source) + self.assertEqual(result["state"], "unresolved") + self.assertEqual(result["reason_code"], "work-limit") + self.assertEqual(result["candidates"], []) + # Branch tracing draws from the per-candidate visit allowance too. + small = _with_positive( + _if_else(["12", 1], ["10", 0], ["11", 0]), + {"10": _encoded(parity.POS), "11": _encoded(WOLF), "12": _image_exists()}, + ) + self.assertEqual(_inspect(small, max_visits=3)["reason_code"], "work-limit") + + def test_conditioning_combine_distinct_texts_is_composite(self): + source = parity.build("f06d")["prompt"] + result = _inspect(source) + self.assertEqual( + (result["state"], result["reason_code"]), ("unresolved", "composite") + ) + self.assertEqual( + result["node"], {"id": "6", "class_type": "ConditioningCombine"} + ) + self.assertIsNone(result["positive"]) + for name, inputs in ( + ("ConditioningConcat", ("conditioning_to", "conditioning_from")), + ("ConditioningAverage", ("conditioning_to", "conditioning_from")), + ): + with self.subTest(name=name): + source["6"] = { + "class_type": name, + "inputs": {inputs[0]: ["10", 0], inputs[1]: ["11", 0]}, + } + result = _inspect(source) + self.assertEqual(result["reason_code"], "composite") + self.assertEqual(result["node"], {"id": "6", "class_type": name}) + # A zeroed part is a different text too. + source["12"] = { + "class_type": "ConditioningZeroOut", + "inputs": {"conditioning": ["10", 0]}, + } + source["6"]["inputs"]["conditioning_from"] = ["12", 0] + self.assertEqual(_inspect(source)["reason_code"], "composite") + + def test_conditioning_combine_identical_texts_resolve(self): + source = parity.build("f06d")["prompt"] + source["11"]["inputs"]["text"] = parity.POS + result = _inspect(source) + self.assertEqual(result["state"], "resolved") + self.assertEqual(_sides(result), (parity.POS, parity.NEG)) + source["6"]["inputs"]["conditioning_2"] = ["10", 0] + self.assertEqual(_inspect(source)["state"], "resolved") + # A missing part is malformed, never "the other part". + del source["6"]["inputs"]["conditioning_2"] + result = _inspect(source) + self.assertEqual(result["state"], "unresolved") + self.assertEqual(result["reason_code"], "malformed") + + def test_default_profile_keeps_conditioning_nodes_unsupported(self): + for name in ("f06c", "f06d", "f06e", "f20", "f23"): + with self.subTest(fixture=name): + result = resolve_prompt_pair({"prompt": parity.build(name)["prompt"]}) + self.assertEqual(result["state"], "unresolved") + self.assertEqual( + result["reason"], "Unsupported node semantics or output slot" + ) + self.assertIsNone(result["reason_code"]) + + 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 cabb6bb..7d299ac 100644 --- a/utils/prompt_pair_resolver.py +++ b/utils/prompt_pair_resolver.py @@ -143,6 +143,10 @@ _DIRECT_SAMPLER = { # which needs output ancestry (later slice), so it is never a candidate alone. _CANDIDATE_STRATEGIES = frozenset({"ksampler", "custom-advanced"}) _GUIDER_ABSENT_REASON = "Guider has no negative input" +# ConditioningZeroOut discards its input text: an explicit empty prompt. +_ZEROED_SOURCE = "zeroed-conditioning" +# Runtime selector choices expanded per sampler (the spec's candidate cap). +_MAX_BRANCH_CANDIDATES = 32 _SAMPLER_LIKE_INPUTS = ("latent_image", "guider", "positive", "negative") _NO_SAMPLER_NAMES = 3 _CLASS_NAME_LENGTH = 64 @@ -159,6 +163,18 @@ class _Unresolved(Exception): self.node = node +class _Diverging(Exception): + """A runtime selector whose branches trace to different texts. + + Raised from inside tracing; ``_GraphResolver.branches`` resolves the sampler + again once per choice, so each candidate is internally consistent. + """ + + def __init__(self, node_id: str) -> None: + super().__init__(node_id) + self.node_id = node_id + + def _node_ref(node_id: str, class_type: Any) -> dict: name = class_type[:_CLASS_NAME_LENGTH] if isinstance(class_type, str) else None return {"id": node_id[:_CLASS_NAME_LENGTH], "class_type": name} @@ -230,6 +246,19 @@ def _result( } +def _role_text(side: Any) -> Optional[str]: + return side["text"] if isinstance(side, dict) else None + + +def _outcome_key(result: Dict[str, Any]) -> tuple: + """What a reader sees of a result: state and the two role texts.""" + return ( + result["state"], + _role_text(result["positive"]), + _role_text(result["negative"]), + ) + + def _legacy_result(result: Dict[str, Any]) -> Dict[str, Any]: """The default profile's wire: today's keys plus ``reason_code: None``.""" legacy = {key: value for key, value in result.items() if key != "node"} @@ -280,6 +309,10 @@ class _GraphResolver: self.active: set = set() self.metadata = metadata or {} self.text_sources: dict = {} + # Forced runtime selector choices (inspection); a result traced through + # one is "tainted" and never memoized, since it depends on the choice. + self.choices: dict = {} + self.tainted = False # ``semantics`` (inspection) names the samplers and guiders it follows. self.semantics = semantics if semantics is None: @@ -326,11 +359,15 @@ class _GraphResolver: self.scanned -= units def unsupported(self, message: str, node_id: str) -> _Unresolved: + return self.named(message, "unsupported-node", node_id) + + def named(self, message: str, code: str, node_id: str) -> _Unresolved: + """An outcome that names the node it is about (bounded class name).""" node = self.graph.get(node_id) class_type = node.get("class_type") if isinstance(node, dict) else None if isinstance(class_type, str): class_type = class_type[:_CLASS_NAME_LENGTH] - return _Unresolved(message, "unsupported-node", _node_ref(node_id, class_type)) + return _Unresolved(message, code, _node_ref(node_id, class_type)) def bounded(self, text: str) -> str: if len(text) > self.max_text_length: @@ -368,6 +405,7 @@ class _GraphResolver: return self.memo[key] allowance = self.budget.allowance outer_deepest = self.deepest + outer_tainted, self.tainted = self.tainted, False self.deepest = depth try: self.budget.spend() @@ -381,6 +419,8 @@ class _GraphResolver: "Reroute input is not uniquely identifiable", "malformed" ) text, path, storage_text = self.text(inputs[keys[0]], kind, depth + 1) + if self.semantics is not None: + self.inherit_source(node_id, slot, [path]) elif (name, slot) in self.adapters: adapter = self.adapters[(name, slot)] if not isinstance(adapter, OutputAdapter) or adapter.kind != kind: @@ -471,6 +511,10 @@ class _GraphResolver: parts.append(part) text = self.built(delimiter.join(parts)) storage_text = text + elif self.semantics is not None and self.follows(name, slot, kind): + text, path, storage_text = self.semantic( + node_id, name, slot, kind, inputs, depth + ) else: raise self.unsupported( "Unsupported node semantics or output slot", node_id @@ -488,14 +532,123 @@ class _GraphResolver: self.path_entries is not None and self.path_entries < 0 ): raise _Unresolved("Provenance path limit reached", "work-limit") - self.memo[key] = (text, path, storage_text) - self.costs[key] = allowance - self.budget.allowance - self.heights[key] = self.deepest - depth + if not self.tainted: + self.memo[key] = (text, path, storage_text) + self.costs[key] = allowance - self.budget.allowance + self.heights[key] = self.deepest - depth return text, path, storage_text finally: self.deepest = max(outer_deepest, self.deepest) + self.tainted = outer_tainted or self.tainted self.active.discard(key) + def follows(self, name: str, slot: int, kind: str) -> bool: + """Whether the semantics table traces this output (inspection only).""" + semantics = self.semantics + if slot == 0 and name in semantics.selectors: + return True + return kind == "conditioning" and ( + (name, slot) in semantics.conditioning + or (slot == 0 and name in semantics.composites) + ) + + def semantic( + self, node_id: str, name: str, slot: int, kind: str, inputs: dict, depth: int + ) -> Tuple[str, list, str]: + """Trace a table-listed pass-through, zeroed, selector or composite output. + + The caller (``text``) prepends this node to the returned path. + """ + semantics = self.semantics + entry = ( + semantics.conditioning.get((name, slot)) if kind == "conditioning" else None + ) + if entry is not None and entry["strategy"] == "zeroed": + # The upstream text is discarded, so it is not traced. + self.text_sources[(node_id, slot)] = _ZEROED_SOURCE + return "", [], "" + if entry is not None: # passthrough: one input carries the text + traced = self.text(inputs.get(entry["input"]), kind, depth + 1) + self.inherit_source(node_id, slot, [traced[1]]) + return traced + if name in semantics.selectors: + return self.select(node_id, semantics.selectors[name], inputs, kind, depth) + parts = [ + self.text(inputs.get(input_name), kind, depth + 1) + for input_name in semantics.composites[name]["inputs"] + ] + if len({part[0] for part in parts}) != 1: + # Several texts are in effect at once: not one sendable prompt. + raise self.named( + "Combined conditioning has different texts", "composite", node_id + ) + return self.merged(node_id, slot, parts) + + def select( + self, node_id: str, entry: Mapping, inputs: dict, kind: str, depth: int + ) -> Tuple[str, list, str]: + """A literal condition picks one branch; a linked one is a runtime choice. + + A runtime choice traces both branches. Equal texts converge; different + texts raise ``_Diverging`` unless this choice is already forced. + """ + condition = inputs.get(entry["condition"]) + branches = entry["branches"] + if type(condition) is bool: + choice: Optional[bool] = condition + elif not _link(condition): + raise _Unresolved("Malformed selector condition", "malformed") + elif node_id in self.choices: + choice = self.choices[node_id] + self.tainted = True + else: + choice = None + if choice is not None: + branch = branches["true" if choice else "false"] + traced = self.text(inputs.get(branch), kind, depth + 1) + self.inherit_source(node_id, 0, [traced[1]]) + return traced + parts = [ + self.text(inputs.get(branches[label]), kind, depth + 1) + for label in ("true", "false") + ] + if parts[0][0] != parts[1][0]: + raise _Diverging(node_id) + return self.merged(node_id, 0, parts) + + def merged(self, node_id: str, slot: int, parts: list) -> Tuple[str, list, str]: + """One text reached through several followed paths (deduplicated).""" + text = parts[0][0] + storages = {part[2] for part in parts} + storage_text = parts[0][2] if len(storages) == 1 else text + paths = [part[1] for part in parts] + self.scan(sum(len(path) for path in paths)) + merged, seen = [], set() + for path in paths: + for step in path: + identity = (step["node_id"], step["output_slot"]) + if identity not in seen: + seen.add(identity) + merged.append(step) + self.inherit_source(node_id, slot, paths) + return text, merged, storage_text + + def source_of(self, path: list) -> str: + if not path: + return "graph-inferred" + origin = path[0] + return self.text_sources.get( + (origin["node_id"], origin["output_slot"]), "graph-inferred" + ) + + def inherit_source(self, node_id: str, slot: int, paths: list) -> None: + """A forwarding node reports its input's text source when unambiguous.""" + sources = {self.source_of(path) for path in paths} + if len(sources) == 1: + self.text_sources[(node_id, slot)] = sources.pop() + else: + self.text_sources.pop((node_id, slot), None) + def execution_record(self, node_id: str, inputs: dict) -> Optional[dict]: try: record = read_pair_execution(self.metadata, node_id) @@ -604,7 +757,6 @@ class _GraphResolver: return self.semantics.samplers[name] def pair(self, sampler_id: str) -> dict: - positive = negative = None self.budget.reset(self.per_candidate_visits) if not self.legacy and self.budget.total <= 0: return _result( @@ -613,13 +765,62 @@ class _GraphResolver: reason="Resolution visit limit reached", reason_code="work-limit", ) + if self.semantics is None: + return self.sides(sampler_id) + return self.branches(sampler_id) + + def branches(self, sampler_id: str) -> dict: + """Resolve once per runtime selector choice that changes a role text. + + Every run draws from the same candidate allowance. Results that agree + collapse to one; otherwise the sampler is ``ambiguous`` with one + candidate per choice set, in true-before-false order. + """ + pending: list = [{}] + results: list = [] + try: + while pending: + self.choices = pending.pop(0) + try: + results.append(self.sides(sampler_id)) + except _Diverging as diverging: + if len(results) + len(pending) + 2 > _MAX_BRANCH_CANDIDATES: + raise _Unresolved("Too many selector branches", "work-limit") + pending.extend( + {**self.choices, diverging.node_id: choice} + for choice in (True, False) + ) + except _Unresolved as error: + return _result( + "unresolved", + sampler_id=sampler_id, + reason=str(error), + reason_code=error.code, + node=error.node, + ) + finally: + self.choices = {} + if all(item["state"] == "unresolved" for item in results): + return results[0] + if len({_outcome_key(item) for item in results}) == 1: + return results[0] + return _result( + "ambiguous", + sampler_id=sampler_id, + reason="Selector branches have different prompts", + candidates=results, + ) + + def sides(self, sampler_id: str) -> dict: + positive = negative = None try: name, inputs = self.node(sampler_id) entry = self.sampler_entry(name) # A second sampler in the latent ancestry denotes a multistage run; # associating the final image with one prompt pair would be a guess. - if entry["latent"] in inputs: - self.latent_source(inputs[entry["latent"]], set(), 0) + latent = entry.get("latent") + if latent is not None and latent in inputs: + self.latent_source(inputs[latent], set(), 0) guider_id, depth = None, 0 conditioning = entry["conditioning"] absent_reason = "Negative input is absent" diff --git a/web/js/workspace/prompt-blocks.mjs b/web/js/workspace/prompt-blocks.mjs index fc53cff..f221b67 100644 --- a/web/js/workspace/prompt-blocks.mjs +++ b/web/js/workspace/prompt-blocks.mjs @@ -11,6 +11,7 @@ const sources = { execution: 'Recorded at execution', 'graph-inferred': 'Inferred from the saved workflow', 'embedded-parameters': 'Embedded generation parameters', + 'zeroed-conditioning': 'Zeroed conditioning (no negative text)', 'saved-association': 'Saved library association', }; const statuses = {