Files
ComfyAssets-ComfyUI_PromptM…/tests/test_inspection_legacy_ports.py
T
Vito cbc53ee9f1 feat(resolver): trace multi-stage chains through pass-through nodes (#308)
* feat(resolver): trace multi-stage chains through pass-through nodes

The inspection resolver (server and local Inspect) now compares a sampler
with the earlier sampler its latent continues through image-space
pass-through nodes: decode, image filter, model upscale, scale and encode,
listed in a new validated `lineage` section of the semantics table. Equal
prompts resolve (hires-fix, refiner); different prompts are
multistage-differs. A later stage whose positive adds conditioning to the
shared base prompt through an additive composite (ConditioningCombine or
Concat) keeps the base pair, the text legacy linked (three-stage Flux).
Image-edit batches stay ambiguous and run-time text stays untraceable.
Ingestion keeps the default profile (D1).

A card no longer shows a seed when the inspector names a chain stage
whose seed differs from the parser's pick.

* feat(resolver): leave out run-time PromptManager parts, labelled

Maintainer option A: when a PromptManager prepend_text or append_text is
fed directly by a node whose output is decided at run time (LoraManager's
TriggerWord Toggle, now a runtime class, or PromptSearchList), the prompt
resolves to the node's own text, as legacy showed. The side's source is
runtime-parts-omitted and the resolved reason says that text added at run
time, such as trigger words, is not included; the prompt block shows that
line (D-NEW-R1). The toggle's output is never computed. Literal and
traced parts are combined as before; an indirect run-time part, a run-time
main text or an unknown feeder keep their outcome. The browser port
changes identically. Ingestion is unchanged.

* fix(resolver): keep run-time and added-conditioning labels honest

A text that left out a run-time PromptManager part is now labelled when
any node on its traced path omitted it, so the label survives encoders,
concatenations and chained PromptManagers. Comparisons include what a
text leaves out, so a partial prompt never equals a complete one: a
branch with trigger words and one without stay ambiguous, and such
stages are multistage-differs. Only classes listed as run time are left
out.

The added-conditioning floor labels its positive (D126, "Conditioning
added at a later stage is not included."); the run-time line is D125.

A card hides its seed only when the stage the inspector names reads a
different seed than the parser; an agreeing seed is kept, as on main.
The browser port moves its lineage, latent and part helpers into
local-resolver-stages.mjs, keeping parity.
2026-10-23 11:09:12 -07:00

949 lines
40 KiB
Python

"""Legacy parser rules ported into the inspection resolver (#240, Task 2.7b).
Every graph here is synthetic (neutral texts, generic names). Each class names
the rule from the parser reconciliation (R1, R3, ...) it ports. The default
(ingestion) profile must stay exactly as it was, so each rule also checks that
``DEFAULT_PROFILE`` ignores it.
"""
import io
import json
import tempfile
import unittest
import unittest.mock
import zlib
from pathlib import Path
from PIL import Image
from utils import prompt_pair_resolver as resolver
from utils.prompt_inspector import (
DUPLICATE_ROLE_BYTES,
MetadataReadError,
inspect_prompt_evidence,
read_inspection_file,
)
from utils.prompt_pair_resolver import resolve_prompt_pair
try:
from tests import inspection_parity as parity
except ImportError: # discovered with ``-s tests``
import inspection_parity as parity
def _inspect(metadata: dict, **options) -> dict:
return resolve_prompt_pair(metadata, profile=resolver.INSPECTION_PROFILE, **options)
def _texts(result: dict) -> tuple:
return tuple(
result[role]["text"] if isinstance(result[role], dict) else None
for role in ("positive", "negative")
)
def _pm_encoder(text: str) -> dict:
"""A PromptManager node whose API class was rewritten to CLIPTextEncode."""
return {
"class_type": "CLIPTextEncode",
"inputs": {
"text": text,
"category": "",
"tags": "",
"search_text": "",
"clip": ["4", 1],
},
}
def _overwritten(widgets=("a cat", "a dog", "a bird"), api_text="a bird") -> dict:
"""Pre-3.2.3 shape: three PromptManager nodes, every API ``text`` overwritten.
KSampler 3 reads node 17 (widget A) for its positive; the saved API text of
17, 113 and 119 is the last-run node's prompt (C) for all three.
"""
prompt = parity.build("f02")["prompt"]
prompt["3"]["inputs"]["positive"] = ["17", 0]
del prompt["6"]
for node_id in ("17", "113", "119"):
prompt[node_id] = _pm_encoder(api_text)
workflow = {
"nodes": [
{"id": int(node_id), "type": "PromptManager", "widgets_values": [widget]}
for node_id, widget in zip(("17", "113", "119"), widgets)
]
+ [{"id": 7, "type": "CLIPTextEncode", "widgets_values": [parity.NEG]}],
"links": [],
}
return {"prompt": prompt, "workflow": workflow}
class TestOverwrittenPromptManagerText(unittest.TestCase):
"""R1 (comfy-metadata.js:52-86): never resolve to the overwritten text."""
def test_overwritten_text_is_refused_not_resolved(self):
result = _inspect(_overwritten())
self.assertEqual(result["state"], "unresolved")
self.assertEqual(result["reason_code"], "malformed")
self.assertEqual(result["reason"], resolver.OVERWRITTEN_PROMPT_REASON)
self.assertIsNone(result["positive"])
def test_the_inspector_passes_the_workflow_to_the_resolver(self):
metadata = _overwritten()
evidence = inspect_prompt_evidence(
{
"prompt": json.dumps(metadata["prompt"]),
"workflow": json.dumps(metadata["workflow"]),
}
)
self.assertEqual(evidence["resolution"]["reason_code"], "malformed")
self.assertEqual(evidence["prompts"]["positive"]["status"], "unresolved")
self.assertNotEqual(evidence["prompts"]["positive"]["text"], "a bird")
self.assertEqual(
evidence["prompts"]["positive"]["message"],
"Prompt metadata in this file is malformed.",
)
def test_untouched_shapes_still_resolve(self):
cases = {
# One PromptManager node, or several whose widgets agree.
"single": ("a bird",),
"agreeing widgets": ("a bird", "a bird", "a bird"),
}
for label, widgets in cases.items():
with self.subTest(label):
metadata = _overwritten()
metadata["workflow"]["nodes"] = [
node
for node in metadata["workflow"]["nodes"]
if node["type"] != "PromptManager"
] + [
{"id": node_id, "type": "PromptManager", "widgets_values": [text]}
for node_id, text in zip((17, 113, 119), widgets)
]
self.assertEqual(_inspect(metadata)["state"], "resolved")
# Distinct API texts are what ran, whatever the widgets say.
metadata = _overwritten()
metadata["prompt"]["113"]["inputs"]["text"] = "a dog"
metadata["prompt"]["119"]["inputs"]["text"] = "a cat"
metadata["prompt"]["17"]["inputs"]["text"] = "a fish"
self.assertEqual(_texts(_inspect(metadata)), ("a fish", parity.NEG))
# A PromptManagerPair widget is its positive and negative: not the signature.
metadata = _overwritten()
for node in metadata["workflow"]["nodes"]:
node["type"] = node["type"].replace("PromptManager", "PromptManagerPair")
self.assertEqual(_inspect(metadata)["state"], "resolved")
# No workflow, or a malformed one, cannot show the signature.
orphans = {
"nodes": [{"id": 5, "type": "PromptManager", "widgets_values": ["a"]}]
}
for workflow in (None, "nodes", {"nodes": "x"}, {"nodes": [1, None]}, orphans):
with self.subTest(workflow=workflow):
metadata = _overwritten()
metadata["workflow"] = workflow
self.assertEqual(_inspect(metadata)["state"], "resolved")
def test_an_untouched_branch_beside_overwritten_nodes_resolves(self):
# Only the overwritten nodes are refused: the negative is not one.
metadata = _overwritten()
metadata["prompt"]["3"]["inputs"]["positive"] = ["7", 0]
self.assertEqual(_texts(_inspect(metadata)), (parity.NEG, parity.NEG))
def test_overwritten_nodes_inside_subgraphs_are_refused(self):
# Review M-1: subgraph nodes run as "<instance>:<inner>" API IDs.
def nested(depth: int = 1) -> dict:
metadata = _overwritten()
prompt, workflow = metadata["prompt"], metadata["workflow"]
prefix = ":".join(["50", "60", "70"][:depth]) + ":"
for node_id in ("17", "113", "119"):
prompt[prefix + node_id] = prompt.pop(node_id)
prompt["3"]["inputs"]["positive"] = [prefix + "17", 0]
inner = [n for n in workflow["nodes"] if n["type"] == "PromptManager"]
outer = [n for n in workflow["nodes"] if n["type"] != "PromptManager"]
subgraphs = []
for level in range(depth, 0, -1):
nodes = (
inner
if level == depth
else [{"id": [50, 60, 70][level], "type": f"sub-{level + 1}"}]
)
subgraphs.append({"id": f"sub-{level}", "nodes": nodes})
metadata["workflow"] = {
"nodes": outer + [{"id": 50, "type": "sub-1", "widgets_values": []}],
"links": [],
"definitions": {"subgraphs": subgraphs},
}
return metadata
for depth in (1, 2, 3):
with self.subTest(depth=depth):
for anchor in (None, "9"):
result = _inspect(nested(depth), output_node_id=anchor)
self.assertEqual(
result["reason"], resolver.OVERWRITTEN_PROMPT_REASON, anchor
)
# A subgraph definition with no instance runs nothing.
metadata = nested()
metadata["workflow"]["nodes"] = metadata["workflow"]["nodes"][:-1]
self.assertEqual(_inspect(metadata)["state"], "resolved")
# Malformed definitions never raise.
for definitions in ("x", {"subgraphs": "x"}, {"subgraphs": [1, {"id": 3}]}):
metadata = nested()
metadata["workflow"]["definitions"] = definitions
self.assertEqual(_inspect(metadata)["state"], "resolved", definitions)
# A self-referencing subgraph is bounded.
metadata = nested()
metadata["workflow"]["definitions"]["subgraphs"].append(
{"id": "loop", "nodes": [{"id": 1, "type": "loop"}]}
)
metadata["workflow"]["nodes"].append({"id": 99, "type": "loop"})
self.assertEqual(
_inspect(metadata)["reason"], resolver.OVERWRITTEN_PROMPT_REASON
)
def test_object_widget_values_name_the_text(self):
metadata = _overwritten()
for node in metadata["workflow"]["nodes"]:
node["widgets_values"] = {"text": node["widgets_values"][0]}
self.assertEqual(
_inspect(metadata)["reason"], resolver.OVERWRITTEN_PROMPT_REASON
)
def test_the_workflow_is_parsed_only_for_prompt_manager_graphs(self):
# Review M-4: no PromptManager-like node, no workflow parse.
from utils import prompt_inspector
prompt = parity.build("f02")["prompt"]
with unittest.mock.patch.object(
prompt_inspector,
"_parsed_workflow",
wraps=prompt_inspector._parsed_workflow,
) as parsed:
inspect_prompt_evidence(
{"prompt": json.dumps(prompt), "workflow": json.dumps({"nodes": []})}
)
self.assertEqual(parsed.call_count, 0)
metadata = _overwritten()
inspect_prompt_evidence(
{
"prompt": json.dumps(metadata["prompt"]),
"workflow": json.dumps(metadata["workflow"]),
}
)
self.assertEqual(parsed.call_count, 1)
def test_default_profile_ignores_the_workflow(self):
result = resolve_prompt_pair(_overwritten())
self.assertEqual(result["state"], "resolved")
self.assertEqual(_texts(result), ("a bird", parity.NEG))
def _historical(class_type: str, slot: int, inputs: dict, kind: str) -> dict:
"""``f02`` whose positive reads ``class_type`` output ``slot`` (node 10).
A conditioning output feeds the sampler directly; a string output feeds a
CLIPTextEncode (node 6).
"""
prompt = parity.build("f02")["prompt"]
prompt["10"] = {"class_type": class_type, "inputs": {"clip": ["4", 1], **inputs}}
if kind == "conditioning":
prompt["3"]["inputs"]["positive"] = ["10", slot]
else:
prompt["6"]["inputs"]["text"] = ["10", slot]
return {"prompt": prompt}
class TestHistoricalPromptManagerNodes(unittest.TestCase):
"""R7: this repository's node classes up to 07a5af3, with their own join."""
COMPOSED = {"text": " a cat ", "prepend_text": " trig ", "append_text": "sky "}
def test_v2_outputs_and_comma_join(self):
inputs = {**self.COMPOSED, "negative_text": " bad hands "}
for slot, kind, expected in (
(0, "conditioning", "trig, a cat, sky"),
(1, "conditioning", "bad hands"),
(2, "string", "trig, a cat, sky"),
(3, "string", "bad hands"),
):
with self.subTest(slot=slot):
result = _inspect(_historical("PromptManagerV2", slot, inputs, kind))
self.assertEqual(result["state"], "resolved")
self.assertEqual(result["positive"]["text"], expected)
def test_comma_rule_matches_the_historical_code(self):
cases = (
({"text": "a cat", "prepend_text": "trig,"}, "trig,a cat"),
({"text": "a cat,", "append_text": "sky"}, "a cat,sky"),
({"text": "", "prepend_text": "trig", "append_text": "sky"}, "trig, sky"),
({"text": " ", "prepend_text": " ", "append_text": "sky"}, "sky"),
({"text": "a cat"}, "a cat"),
# Review M-5: an explicit null part is skipped, as ``if prepend_text:``.
({"text": "a cat", "prepend_text": None, "append_text": None}, "a cat"),
)
for inputs, expected in cases:
with self.subTest(inputs=inputs):
result = _inspect(_historical("PromptManagerV2", 2, inputs, "string"))
self.assertEqual(result["positive"]["text"], expected)
def test_single_role_classes(self):
cases = (
("PromptManagerPositive", 0, "conditioning", "trig, a cat, sky"),
("PromptManagerPositive", 1, "string", "trig, a cat, sky"),
("PromptManagerDetailer", 0, "conditioning", "trig, a cat, sky"),
("PromptManagerDetailer", 1, "string", "trig, a cat, sky"),
("PromptManagerV2Text", 0, "string", "trig, a cat, sky"),
("PromptManagerPositiveText", 0, "string", "trig, a cat, sky"),
("PromptManagerNegativeText", 0, "string", "trig, a cat, sky"),
# The negative-only encoder never composes.
("PromptManagerNegative", 0, "conditioning", "a cat"),
("PromptManagerNegative", 1, "string", "a cat"),
)
for class_type, slot, kind, expected in cases:
with self.subTest(node=class_type, slot=slot):
result = _inspect(_historical(class_type, slot, self.COMPOSED, kind))
self.assertEqual(result["state"], "resolved")
self.assertEqual(result["positive"]["text"], expected)
result = _inspect(
_historical(
"PromptManagerV2Text",
1,
{"text": "a cat", "negative_text": " bad "},
"string",
)
)
self.assertEqual(result["positive"]["text"], "bad")
def test_optional_negative_defaults_to_empty(self):
result = _inspect(
_historical("PromptManagerV2", 0, {"text": "a cat"}, "conditioning")
)
self.assertEqual(result["positive"]["text"], "a cat")
prompt = _historical("PromptManagerV2", 0, {"text": "a cat"}, "conditioning")
prompt["prompt"]["3"]["inputs"]["negative"] = ["10", 1]
self.assertEqual(_texts(_inspect(prompt)), ("a cat", ""))
def test_unlisted_outputs_and_kinds_are_not_guessed(self):
# PromptManagerDetailer output 2 is an INT prompt id.
result = _inspect(
_historical("PromptManagerDetailer", 2, self.COMPOSED, "string")
)
self.assertEqual(result["reason_code"], "unsupported-node")
# A STRING output read as conditioning is a malformed link.
result = _inspect(
_historical("PromptManagerV2", 2, self.COMPOSED, "conditioning")
)
self.assertEqual(result["reason_code"], "malformed")
def test_linked_parts_are_traced(self):
source = _historical(
"PromptManagerV2",
0,
{"text": ["11", 0], "prepend_text": ["12", 0]},
"conditioning",
)
source["prompt"]["11"] = {
"class_type": "PrimitiveString",
"inputs": {"value": "a cat"},
}
source["prompt"]["12"] = {
"class_type": "PrimitiveString",
"inputs": {"value": "trig"},
}
result = _inspect(source)
self.assertEqual(result["positive"]["text"], "trig, a cat")
self.assertEqual(
[step["node_id"] for step in result["positive"]["provenance"]["path"]],
["10", "12", "11"],
)
def test_overwritten_historical_nodes_are_still_refused(self):
metadata = _overwritten()
for node_id in ("17", "113", "119"):
metadata["prompt"][node_id]["class_type"] = "PromptManagerV2"
for node in metadata["workflow"]["nodes"]:
if node["type"] == "PromptManager":
node["type"] = "PromptManagerV2"
self.assertEqual(_inspect(metadata)["reason_code"], "malformed")
def test_default_profile_keeps_them_unsupported(self):
result = resolve_prompt_pair(
_historical("PromptManagerV2", 0, self.COMPOSED, "conditioning")
)
self.assertEqual(result["state"], "unresolved")
self.assertEqual(result["reason"], "Unsupported node semantics or output slot")
FOOTER = "Steps: 20, Sampler: Euler, CFG scale: 7, Seed: 5"
def _parameters(text: str, *, legacy: bool = False) -> dict:
profile = resolver.DEFAULT_PROFILE if legacy else resolver.INSPECTION_PROFILE
return resolve_prompt_pair({"parameters": text}, profile=profile)
class TestA1111Text(unittest.TestCase):
"""R4 and R5 (#165, py/api/__init__.py:738-750) for inspection."""
def test_text_starting_with_the_negative_delimiter_has_no_positive(self):
for text in (
f"Negative prompt: bad\n{FOOTER}",
f"\nNegative prompt: bad\n{FOOTER}",
):
with self.subTest(text=text):
result = _parameters(text)
self.assertEqual(result["state"], "unresolved")
self.assertEqual(result["reason"], resolver.EMPTY_POSITIVE_REASON)
self.assertIsNone(result["positive"])
# Two delimiters, one at the start, are still two.
result = _parameters(f"Negative prompt: a\nNegative prompt: b\n{FOOTER}")
self.assertEqual(result["state"], "ambiguous")
def test_dynamic_prompts_template_lines_follow_the_footer(self):
base = f"a cat\nNegative prompt: bad\n{FOOTER}"
for trailer in (
"\nTemplate: a {cat|dog}",
"\nTemplate: a {cat|dog}\nNegative Template: bad",
"\nNegative Template: bad",
"\nTemplate: a {cat|dog}\nNegative Template: bad\n",
):
with self.subTest(trailer=trailer):
result = _parameters(base + trailer)
self.assertEqual(result["state"], "resolved")
self.assertEqual(_texts(result), ("a cat", "bad"))
for trailer in (
"\nTemplate: a\nTemplate: b",
"\nNegative Template: bad\nTemplate: a",
"\nTemplate: a\nmore template text",
"\ntrailing",
):
with self.subTest(trailer=trailer):
result = _parameters(base + trailer)
self.assertEqual(result["reason_code"], "malformed")
def test_footer_with_only_steps(self):
result = _parameters("a cat\nNegative prompt: bad\nSteps: 20")
self.assertEqual(_texts(result), ("a cat", "bad"))
result = _parameters("a cat\nSteps: 20\nTemplate: a {cat|dog}")
self.assertEqual((result["state"], _texts(result)[0]), ("absent", "a cat"))
# A full footer wins over a bare ``Steps: N`` line in the prompt text.
result = _parameters(f"a cat\nSteps: 3\nNegative prompt: bad\n{FOOTER}")
self.assertEqual(_texts(result), ("a cat\nSteps: 3", "bad"))
result = _parameters("a cat\nSteps: 3\nNegative prompt: bad\nSteps: 20")
self.assertEqual(result["reason_code"], "malformed")
def test_default_profile_keeps_the_historical_split(self):
result = _parameters(f"Negative prompt: bad\n{FOOTER}", legacy=True)
self.assertEqual(_texts(result), ("Negative prompt: bad", None))
for text in (
f"a cat\nNegative prompt: bad\n{FOOTER}\nTemplate: a {{cat|dog}}",
"a cat\nNegative prompt: bad\nSteps: 20",
):
with self.subTest(text=text):
result = _parameters(text, legacy=True)
self.assertEqual(
result["reason"], "A1111 settings footer is not unique"
)
def _chunk(keyword: str, text: str, kind: bytes = b"tEXt") -> bytes:
body = text.encode("latin-1")
if kind == b"zTXt":
body = b"\0" + zlib.compress(body)
elif kind == b"iTXt":
body = b"\0\0\0\0" + text.encode("utf-8")
return parity._raw_chunk(kind, keyword.encode("latin-1") + b"\0" + body)
def _batch(index: int, positive: str = "a cat", seed: int = 5) -> str:
return (
f"{positive}\nNegative prompt: bad\nSteps: 20, Sampler: Euler, "
f"CFG scale: 7, Seed: {seed}, Batch index: {index}, Batch size: 12"
)
class TestBatchIndexDuplicates(unittest.TestCase):
"""R3: an accumulating batch saver repeats ``parameters`` per batch item."""
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.directory = Path(temporary.name)
def png(self, chunks, name="batch.png"):
buffer = io.BytesIO()
with Image.new("RGB", (4, 4), (40, 80, 120)) as image:
image.save(buffer, format="PNG")
data = buffer.getvalue()
iend = data.rindex(b"IEND") - 4
path = self.directory / name
path.write_bytes(data[:iend] + b"".join(chunks) + data[iend:])
return path
def test_copies_differing_only_in_batch_index_read_as_the_last(self):
cases = {
"same length": [0, 1, 2],
"growing length": [8, 9, 10, 11],
"any order": [3, 0],
"repeat of an earlier copy": [0, 1, 0],
}
for label, indexes in cases.items():
with self.subTest(label):
path = self.png([_chunk("parameters", _batch(i)) for i in indexes])
read = read_inspection_file(path)
self.assertEqual(read.metadata["parameters"], _batch(indexes[-1]))
self.assertEqual(read.record_formats["parameters"], "original-text")
evidence = inspect_prompt_evidence(read.metadata)
self.assertEqual(evidence["resolution"]["state"], "resolved")
self.assertEqual(evidence["prompts"]["positive"]["text"], "a cat")
# Encodings may differ; the decoded copies are compared.
path = self.png(
[
_chunk("parameters", _batch(0)),
_chunk("parameters", _batch(1), b"zTXt"),
_chunk("parameters", _batch(2), b"iTXt"),
]
)
self.assertEqual(read_inspection_file(path).metadata["parameters"], _batch(2))
def test_any_other_difference_is_still_a_conflict(self):
cases = {
"positive": [_batch(0), _batch(1, positive="a dog")],
"seed": [_batch(0), _batch(1, seed=6)],
"no batch index": [_batch(0), _batch(0).replace(", Batch index: 0", "")],
"two batch indexes": [_batch(0), _batch(1) + ", Batch index: 2"],
"prose": [_batch(0), _batch(1).replace("a cat", "a cat Batch index: 1")],
# Review I-1: the field is matched only in the settings footer.
"prompt field": [
_batch(0).replace("a cat", "a cat, Batch index: 0"),
_batch(0).replace("a cat", "a cat, Batch index: 1"),
],
"prompt only": [
"a cat, Batch index: 0\nNegative prompt: bad\nSteps: 20, Seed: 5",
"a cat, Batch index: 1\nNegative prompt: bad\nSteps: 20, Seed: 5",
],
"negative field": [
_batch(0).replace("bad", "bad, Batch index: 0"),
_batch(1).replace("bad", "bad, Batch index: 1"),
],
"no footer": [
"a cat\nBatch index: 0, Batch size: 2",
"a cat\nBatch index: 1, Batch size: 2",
],
}
for label, texts in cases.items():
with self.subTest(label):
path = self.png([_chunk("parameters", text) for text in texts])
with self.assertRaisesRegex(
MetadataReadError, "Duplicate prompt metadata"
):
read_inspection_file(path)
# The rule is only for ``parameters``: prompt graphs stay byte-identical.
prompt = json.dumps(parity.build("f02")["prompt"])
path = self.png([_chunk("prompt", prompt), _chunk("prompt", prompt + " ")])
with self.assertRaisesRegex(MetadataReadError, "Duplicate prompt metadata"):
read_inspection_file(path)
def test_decoded_copies_are_bounded(self):
# Small compressed copies must not decode unbounded text.
text = _batch(0) + "," + " " * (DUPLICATE_ROLE_BYTES // 8)
chunks = [
_chunk("parameters", text.replace("index: 0", f"index: {i}"), b"zTXt")
for i in range(10)
]
path = self.png(chunks)
with self.assertRaisesRegex(MetadataReadError, "exceeds inspection limits"):
read_inspection_file(path)
# The kept copy still fits the role budget when a later one is longer.
from utils.prompt_inspector import ROLE_METADATA_BYTES
first = _batch(9)
first += "," + " " * (ROLE_METADATA_BYTES - len(first) - 1)
later = first.replace("index: 9", "index: 10")
for copy in (later, later + " " * ROLE_METADATA_BYTES):
with self.subTest(size=len(copy)):
path = self.png(
[
_chunk("parameters", first, b"zTXt"),
_chunk("parameters", copy, b"zTXt"),
]
)
with self.assertRaisesRegex(
MetadataReadError, "exceeds inspection limits"
):
read_inspection_file(path)
def _f02(**changes) -> dict:
"""``f02`` (KSampler 3, encoders 6 and 7) with nodes added or replaced."""
prompt = parity.build("f02")["prompt"]
prompt.update(changes)
return prompt
def _stage(class_type: str, image_input: str, positive=("6", 0), **inputs) -> dict:
"""``f19``'s shape with ``class_type`` as the detailer stage (node 50)."""
prompt = parity.build("f19")["prompt"]
if "upscale_model" in inputs:
prompt["12"] = {"class_type": "UpscaleModelLoader", "inputs": {}}
if "segs" in inputs:
prompt["13"] = {"class_type": "SegsDetector", "inputs": {"image": ["8", 0]}}
prompt["50"] = {
"class_type": class_type,
"inputs": {
image_input: ["8", 0],
"model": ["4", 0],
"vae": ["4", 2],
"positive": list(positive),
"negative": ["7", 0],
"seed": 7,
"steps": 12,
"cfg": 4.5,
"sampler_name": "euler",
"scheduler": "normal",
"denoise": 0.4,
**inputs,
},
}
return prompt
DETAILERS = (
("UltimateSDUpscale", "image", {"upscale_model": ["12", 0], "upscale_by": 2}),
("UltimateSDUpscaleNoUpscale", "upscaled_image", {}),
("DetailerForEach", "image", {"segs": ["13", 0], "wildcard": ""}),
)
class TestInstalledNodeSemantics(unittest.TestCase):
"""R6 entries verified against the installed node sources (see the report)."""
def test_upscale_and_segs_detailers_are_stages(self):
for class_type, image, extra in DETAILERS:
with self.subTest(node=class_type):
same = _inspect({"prompt": _stage(class_type, image, **extra)})
self.assertEqual(same["state"], "resolved")
self.assertEqual(same["sampler_id"], "50")
self.assertEqual(_texts(same), (parity.POS, parity.NEG))
other = _inspect(
{"prompt": _stage(class_type, image, ("10", 0), **extra)}
)
self.assertEqual(other["state"], "ambiguous")
self.assertEqual(other["reason_code"], "multistage-differs")
self.assertEqual(
sorted(item["sampler_id"] for item in other["candidates"]),
["3", "50"],
)
def test_a_detailer_wildcard_replaces_its_prompt(self):
# Impact Pack core.py: a non-empty wildcard is encoded instead of positive.
for class_type in ("DetailerForEach", "FaceDetailer"):
with self.subTest(node=class_type):
result = _inspect(
{"prompt": _stage(class_type, "image", wildcard="a smiling face")}
)
self.assertEqual(result["state"], "unresolved")
self.assertEqual(result["reason_code"], "runtime-value")
self.assertEqual(result["node"], {"id": "50", "class_type": class_type})
linked = _stage(class_type, "image", wildcard=["14", 0])
linked["14"] = {"class_type": "WildcardSource", "inputs": {}}
result = _inspect({"prompt": linked})
self.assertEqual(result["reason_code"], "unsupported-node")
self.assertEqual(
result["node"], {"id": "14", "class_type": "WildcardSource"}
)
for empty in ("", None):
result = _inspect(
{"prompt": _stage(class_type, "image", wildcard=empty)}
)
self.assertEqual(result["state"], "resolved", empty)
def test_conditioning_passthroughs_keep_each_role(self):
pair = {"positive": ["6", 0], "negative": ["7", 0]}
cases = {
"ControlNetInpaintingAliMamaApply": (
{
**pair,
"control_net": ["11", 0],
"vae": ["4", 2],
"image": ["12", 0],
"mask": ["12", 1],
"strength": 1.0,
},
0,
1,
),
"LTXVConditioning": ({**pair, "frame_rate": 25.0}, 0, 1),
"FluxKontextMultiReferenceLatentMethod": (
{"conditioning": ["6", 0], "reference_latents_method": "offset"},
0,
None,
),
}
for class_type, (inputs, positive, negative) in cases.items():
with self.subTest(node=class_type):
prompt = _f02(**{"20": {"class_type": class_type, "inputs": inputs}})
prompt["3"]["inputs"]["positive"] = ["20", positive]
if negative is not None:
prompt["3"]["inputs"]["negative"] = ["20", negative]
result = _inspect({"prompt": prompt})
self.assertEqual(result["state"], "resolved")
self.assertEqual(_texts(result), (parity.POS, parity.NEG))
self.assertEqual(
result["positive"]["provenance"]["path"][0]["node_id"], "20"
)
if negative is None:
prompt["3"]["inputs"]["negative"] = ["20", 1]
self.assertEqual(
_inspect({"prompt": prompt})["reason_code"], "unsupported-node"
)
def test_text_nodes(self):
prompt = _f02(
**{
"6": {
"class_type": "Text to Conditioning",
"inputs": {"clip": ["4", 1], "text": ["21", 0]},
},
"21": {"class_type": "DisplayText", "inputs": {"text": ["22", 0]}},
"22": {"class_type": "TextInput", "inputs": {"text": parity.POS}},
}
)
result = _inspect({"prompt": prompt})
self.assertEqual(_texts(result), (parity.POS, parity.NEG))
self.assertEqual(
[step["node_id"] for step in result["positive"]["provenance"]["path"]],
["6", "21", "22"],
)
# Text to Conditioning has one output; TextInput reads only ``text``.
prompt["3"]["inputs"]["positive"] = ["6", 1]
self.assertEqual(
_inspect({"prompt": prompt})["reason_code"], "unsupported-node"
)
def test_overwritten_text_behind_a_new_passthrough_is_still_refused(self):
# R1 before R6: the pass-through must not let overwritten text resolve.
metadata = _overwritten()
metadata["prompt"]["20"] = {
"class_type": "FluxKontextMultiReferenceLatentMethod",
"inputs": {"conditioning": ["17", 0], "reference_latents_method": "offset"},
}
metadata["prompt"]["3"]["inputs"]["positive"] = ["20", 0]
result = _inspect(metadata)
self.assertEqual(result["reason"], resolver.OVERWRITTEN_PROMPT_REASON)
def test_default_profile_keeps_them_unsupported(self):
for class_type, image, extra in DETAILERS:
with self.subTest(node=class_type):
result = resolve_prompt_pair(
{"prompt": _stage(class_type, image, **extra)}
)
# The historical rule: the one KSampler, whatever reads its image.
self.assertEqual(result["sampler_id"], "3")
prompt = _f02(
**{"6": {"class_type": "Text to Conditioning", "inputs": {"text": "a"}}}
)
self.assertEqual(
resolve_prompt_pair({"prompt": prompt})["reason"],
"Unsupported node semantics or output slot",
)
def _sampled(class_type: str, latent: str = "latent_image", **inputs) -> dict:
"""``f02`` with its KSampler (node 3) replaced by ``class_type``."""
prompt = _f02()
sampler = dict(prompt["3"]["inputs"], **inputs)
sampler[latent] = sampler.pop("latent_image")
prompt["3"] = {"class_type": class_type, "inputs": sampler}
return prompt
SAMPLERS = (
("ClownsharKSampler_Beta", "latent_image", "options"),
("KSampler (Efficient)", "latent_image", "script"),
("ttN KSampler_v2", "latent", "adv_xyPlot"),
)
class TestUpstreamVerifiedNodeSemantics(unittest.TestCase):
"""R6 entries for nodes not installed here, read from their pinned sources.
Each source is the commit the maintainer's saved workflows name in the
node's ``cnr_id``/``ver`` properties (the report lists repository, commit,
file and line).
"""
def test_ksampler_shaped_samplers_resolve(self):
for class_type, latent, _ in SAMPLERS:
with self.subTest(node=class_type):
result = _inspect({"prompt": _sampled(class_type, latent)})
self.assertEqual(result["state"], "resolved")
self.assertEqual(result["sampler_id"], "3")
self.assertEqual(_texts(result), (parity.POS, parity.NEG))
def test_inputs_that_replace_the_prompt_are_not_followed(self):
# RES4LYF options may carry a guider; Efficiency scripts and the ttN
# XY plot re-run the sampler with other settings.
for class_type, latent, override in SAMPLERS:
with self.subTest(node=class_type):
prompt = _sampled(class_type, latent, **{override: ["30", 0]})
prompt["30"] = {"class_type": "SomeScript", "inputs": {}}
result = _inspect({"prompt": prompt})
self.assertEqual(result["reason_code"], "unsupported-node")
self.assertEqual(
result["node"], {"id": "30", "class_type": "SomeScript"}
)
def test_chained_samplers_are_stages(self):
prompt = _sampled("ClownsharKSampler_Beta")
prompt["31"] = dict(prompt["3"], inputs=dict(prompt["3"]["inputs"]))
prompt["31"]["inputs"]["latent_image"] = ["3", 0]
prompt["8"]["inputs"]["samples"] = ["31", 0]
# 31 continues 3's latent: the stages are compared, as for KSampler.
for anchor in (None, "9"):
result = _inspect({"prompt": prompt}, output_node_id=anchor)
self.assertEqual(
(result["state"], result["sampler_id"]), ("resolved", "31")
)
prompt["31"]["inputs"]["positive"] = ["7", 0]
result = _inspect({"prompt": prompt}, output_node_id="9")
self.assertEqual(result["reason_code"], "multistage-differs")
def test_seed_variance_enhancer_passes_its_conditioning(self):
prompt = _f02(
**{
"20": {
"class_type": "SeedVarianceEnhancer",
"inputs": {"conditioning": ["6", 0], "seed": 1, "strength": 20.0},
}
}
)
prompt["3"]["inputs"]["positive"] = ["20", 0]
self.assertEqual(_texts(_inspect({"prompt": prompt})), (parity.POS, parity.NEG))
def test_overwritten_text_behind_seed_variance_is_still_refused(self):
# R1 before R6: the recon's 24 files differ from this only in names.
metadata = _overwritten()
metadata["prompt"]["20"] = {
"class_type": "SeedVarianceEnhancer",
"inputs": {"conditioning": ["17", 0], "seed": 1},
}
metadata["prompt"]["3"]["inputs"]["positive"] = ["20", 0]
result = _inspect(metadata)
self.assertEqual(result["state"], "unresolved")
self.assertEqual(result["reason"], resolver.OVERWRITTEN_PROMPT_REASON)
def test_instantid_advanced_outputs(self):
prompt = _f02(
**{
"20": {
"class_type": "ApplyInstantIDAdvanced",
"inputs": {
"model": ["4", 0],
"positive": ["6", 0],
"negative": ["7", 0],
"ip_weight": 0.8,
},
}
}
)
prompt["3"]["inputs"].update(positive=["20", 1], negative=["20", 2])
self.assertEqual(_texts(_inspect({"prompt": prompt})), (parity.POS, parity.NEG))
prompt["3"]["inputs"]["positive"] = ["20", 0] # output 0 is the MODEL
self.assertEqual(
_inspect({"prompt": prompt})["reason_code"], "unsupported-node"
)
def test_crystools_conditioning_switch(self):
def switch(condition) -> dict:
prompt = _f02(
**{
"11": _pm_encoder("a dog"),
"20": {
"class_type": "Switch conditioning [Crystools]",
"inputs": {
"on_true": ["6", 0],
"on_false": ["11", 0],
"boolean": condition,
},
},
"21": {"class_type": "PrimitiveBoolean", "inputs": {"value": True}},
}
)
prompt["3"]["inputs"]["positive"] = ["20", 0]
return _inspect({"prompt": prompt})
self.assertEqual(_texts(switch(True))[0], parity.POS)
self.assertEqual(_texts(switch(False))[0], "a dog")
result = switch(["21", 0])
self.assertEqual(result["state"], "ambiguous")
self.assertEqual(
sorted(_texts(item)[0] for item in result["candidates"]),
sorted([parity.POS, "a dog"]),
)
def test_smz_encoder_reads_text_unless_sdxl_may_apply(self):
def smz(**inputs) -> dict:
node = {
"class_type": "smZ CLIPTextEncode",
"inputs": {
"clip": ["4", 1],
"text": "a cat",
"text_g": "a cat",
"text_l": "a cat",
"with_SDXL": False,
"parser": "comfy",
**inputs,
},
}
return _inspect({"prompt": _f02(**{"6": node})})
self.assertEqual(_texts(smz(text_g="", text_l="x")), ("a cat", parity.NEG))
# with_SDXL: the connected CLIP decides at run time which inputs are read.
self.assertEqual(_texts(smz(with_SDXL=True)), ("a cat", parity.NEG))
for parts in ({"text_g": "x"}, {"text_l": ""}, {"text": "x"}):
with self.subTest(parts=parts):
result = smz(with_SDXL=True, **parts)
self.assertEqual(result["reason_code"], "runtime-value")
self.assertEqual(
result["node"], {"id": "6", "class_type": "smZ CLIPTextEncode"}
)
linked = smz(with_SDXL=["21", 0])
self.assertEqual(linked["reason_code"], "unsupported-node")
for broken in ({"with_SDXL": "yes"}, {"with_SDXL": None}):
with self.subTest(flag=broken):
self.assertEqual(smz(**broken)["reason_code"], "malformed")
def test_string_sources(self):
cases = (
("ttN text", "text"),
("CR Text", "text"),
("CR Prompt Text", "prompt"),
("TextBoxMira", "text"),
)
for class_type, name in cases:
with self.subTest(node=class_type):
prompt = _f02(
**{"10": {"class_type": class_type, "inputs": {name: parity.POS}}}
)
prompt["6"]["inputs"]["text"] = ["10", 0]
self.assertEqual(
_texts(_inspect({"prompt": prompt})), (parity.POS, parity.NEG)
)
# Output 1 of the Comfyroll nodes is their help URL.
prompt["6"]["inputs"]["text"] = ["10", 1]
self.assertEqual(
_inspect({"prompt": prompt})["reason_code"], "unsupported-node"
)
def test_default_profile_keeps_them_unsupported(self):
for class_type, latent, _ in SAMPLERS:
with self.subTest(node=class_type):
result = resolve_prompt_pair({"prompt": _sampled(class_type, latent)})
self.assertEqual(result["reason"], "No supported sampler pair found")
if __name__ == "__main__":
unittest.main()