Files
ComfyAssets-ComfyUI_PromptM…/tests/test_resolver_generic_pass_through.py
T
Vito a0fc5171e3 fix(inspector): pass text through unknown nodes instead of refusing them (#381) (#383)
* fix(inspector): pass text through unknown nodes instead of refusing them (#381)

The inspector refused any node class missing from the semantics table, so a
PromptManagerPair behind an rgthree Any Switch showed "Prompt uses a node the
inspector does not support". The inspection resolver (Python and browser) now
reads through unknown nodes: a text input is the text, positive/negative route
by output slot, a conditioning input is followed alone, otherwise every linked
input except core type inputs (pass_through.skip_inputs). PromptManager nodes
are authoritative; several different texts become one candidate each
("Several prompts reach the sampler"). A seeded text node with no conditioning
input is generated at run time, and a recorded PromptManager execution is not
checked against text that passed through an unknown node.

INSPECTION_RESOLVER_VERSION goes to 2; ingestion keeps the default profile.
A legacy parity gate records every committed graph whose inspection positive
matches the legacy parser's; no graph may leave it.

* fix(inspector): keep recorded pair text, string-only seed rule, role inputs and unique candidates (#381)

Review of the pass-through: a walk through an unknown node never overrides a
PromptManagerPair's execution record (any outcome but work-limit is
unverifiable while the record is checked); the run-time seed rule reads only
string outputs; a node with positive/negative inputs follows only the role of
the requested slot, nothing when it is unlinked; one text offered twice is one
candidate.

* fix(inspector): anchor on the saver that wrote the file and resolve stages on shared links (#381)

An inspected file's name now anchors the inspection on the one saver whose
filename_prefix (literal or traced) makes the file's stem <prefix>_<digits>[_];
the samplers in that saver's image ancestry are the candidates. No match,
several matches or an unreadable walk anchor nothing, so the file stays
"several possible prompts". In a latent walk, an earlier sampler reading the
same conditioning links as the later one is the same prompt, not an
unattributed stage. Python and the browser resolver behave the same; the
legacy parity gate states each graph that differs from legacy on purpose.

* fix(inspector): skip only plain first stages on shared links; an unfinished prefix scan anchors nothing (#381)

Review of the saver anchor: a same-links earlier stage is skipped only when it
is not a detailer, sets no prompt override and continues no other sampler, so
an earlier prompt or override behind it is never hidden. A work limit while
tracing a saver prefix ends the match scan without an anchor instead of
counting that saver as no match.

* test(inspector): cover every plain-first-stage outcome on both resolvers (#381)

The frontend coverage gate found local-resolver-stages.mjs branches at 92.44%
(< 93). Each way an earlier stage stops being a plain first stage (no latent,
literal, missing or cyclic latent, a sampler-shaped node, a detailer) is now a
resolver test and an anchor-first-stage-* parity vector.
2026-10-27 16:56:37 +00:00

392 lines
16 KiB
Python

"""Unknown nodes between the prompt and the sampler pass through (#381).
The inspection resolver no longer needs a node class on an allow-list to read
through it. An output the semantics table does not describe passes through: a
``text`` input is the text, a ``positive``/``negative`` input routes by output
slot, a ``conditioning`` input is followed alone, otherwise every linked input
except core type inputs (``pass_through.skip_inputs``) is followed. One text,
or several equal texts, resolve; PromptManager nodes are authoritative; several
different texts are one candidate each. Ingestion (the default profile) is
unchanged. Every graph here is synthetic, with neutral texts.
"""
import json
import unittest
from utils import prompt_pair_resolver as resolver
from utils.prompt_inspector import inspect_prompt_evidence
from utils.prompt_pair_resolver import resolve_prompt_pair
try:
from tests import unknown_node_graphs as graphs
except ImportError: # discovered with ``-s tests``
import unknown_node_graphs as graphs
POSITIVE, OTHER, NEGATIVE = graphs.POSITIVE, graphs.OTHER, graphs.NEGATIVE
any_switch_graph = graphs.any_switch_graph
_encode, _pair, _with = graphs.encode, graphs.pair, graphs.with_nodes
def _inspect(graph, **options):
return resolve_prompt_pair(
{"prompt": graph}, profile=resolver.INSPECTION_PROFILE, **options
)
def _texts(result):
return tuple(
result[role]["text"] if isinstance(result[role], dict) else None
for role in ("positive", "negative")
)
class TestTheReportedGraph(unittest.TestCase):
def test_prompt_manager_pair_through_any_switch_resolves(self):
result = _inspect(any_switch_graph())
self.assertEqual(result["state"], "resolved", result["reason"])
self.assertEqual(_texts(result), (POSITIVE, ""))
self.assertEqual(result["sampler_id"], "17")
self.assertIsNone(result["reason_code"])
path = [step["node_id"] for step in result["positive"]["provenance"]["path"]]
self.assertEqual(path, ["15", "10"])
def test_the_inspector_shows_the_prompt(self):
evidence = inspect_prompt_evidence({"prompt": json.dumps(any_switch_graph())})
self.assertEqual(evidence["resolution"]["state"], "resolved")
self.assertEqual(evidence["prompts"]["positive"]["status"], "resolved")
self.assertEqual(evidence["prompts"]["positive"]["text"], POSITIVE)
def test_ingestion_is_unchanged(self):
result = resolve_prompt_pair({"prompt": any_switch_graph()})
self.assertEqual(result["state"], "unresolved")
# Its first failure is the custom latent node, as before this change.
self.assertEqual(result["reason"], "Unsupported latent ancestry")
def test_the_inspection_version_moves(self):
self.assertEqual(resolver.INSPECTION_RESOLVER_VERSION, 2)
self.assertEqual(resolver.RESOLVER_VERSION, 1)
class TestSourcesThroughUnknownNodes(unittest.TestCase):
def test_several_inputs_with_one_text_resolve(self):
graph = any_switch_graph(any_01=["10", 0], any_02=["10", 0])
self.assertEqual(_texts(_inspect(graph)), (POSITIVE, ""))
def test_equal_texts_from_different_nodes_resolve(self):
graph = _with(
any_switch_graph(any_01=["20", 0], any_02=["21", 0]),
**{"20": _encode(OTHER), "21": _encode(OTHER)},
)
result = _inspect(graph)
self.assertEqual(result["state"], "resolved")
self.assertEqual(_texts(result)[0], OTHER)
def test_different_texts_are_candidates_not_a_refusal(self):
graph = _with(
any_switch_graph(any_01=["20", 0], any_02=["21", 0]),
**{"20": _encode(POSITIVE), "21": _encode(OTHER)},
)
result = _inspect(graph)
self.assertEqual(result["state"], "ambiguous")
self.assertEqual(result["reason"], resolver.SEVERAL_SOURCES_REASON)
self.assertEqual(
[_texts(item) for item in result["candidates"]],
[(POSITIVE, ""), (OTHER, "")],
)
for item in result["candidates"]:
self.assertEqual(item["state"], "resolved")
self.assertEqual(item["sampler_id"], "17")
def test_prompt_manager_is_authoritative(self):
graph = _with(
any_switch_graph(any_01=["20", 0], any_02=["10", 0]),
**{"20": _encode(OTHER)},
)
result = _inspect(graph)
self.assertEqual(result["state"], "resolved")
self.assertEqual(_texts(result), (POSITIVE, ""))
def test_prompt_manager_wins_over_an_unreadable_branch(self):
graph = _with(
any_switch_graph(any_01=["20", 0], any_02=["10", 0]),
**{
"20": {
"class_type": "CLIPTextEncode",
"inputs": {"text": ["21", 0], "clip": ["7", 0]},
},
"21": {"class_type": "PromptSearchList", "inputs": {"search": "x"}},
},
)
self.assertEqual(_texts(_inspect(graph)), (POSITIVE, ""))
def test_an_unreadable_branch_blocks_without_prompt_manager(self):
graph = _with(
any_switch_graph(any_01=["20", 0], any_02=["22", 0]),
**{
"20": {
"class_type": "CLIPTextEncode",
"inputs": {"text": ["21", 0], "clip": ["7", 0]},
},
"21": {"class_type": "PromptSearchList", "inputs": {"search": "x"}},
"22": _encode(OTHER),
},
)
result = _inspect(graph)
self.assertEqual(result["state"], "unresolved")
self.assertEqual(result["reason_code"], "runtime-value")
def test_chained_unknown_nodes(self):
graph = _with(
any_switch_graph(any_01=["20", 0]),
**{
"20": {
"class_type": "Router Node (vendor)",
"inputs": {"in": ["10", 0]},
}
},
)
self.assertEqual(_texts(_inspect(graph)), (POSITIVE, ""))
def test_no_text_names_the_unknown_node(self):
graph = any_switch_graph(any_01=["7", 0])
result = _inspect(graph)
self.assertEqual(result["state"], "unresolved")
self.assertEqual(result["reason_code"], "unsupported-node")
self.assertEqual(
result["node"], {"id": "15", "class_type": "Any Switch (rgthree)"}
)
def test_another_sampler_is_never_entered(self):
graph = any_switch_graph(any_01=["17", 0], any_02=["10", 0])
graph["30"] = {
"class_type": "KSampler",
"inputs": {**graph["17"]["inputs"], "positive": ["31", 0]},
}
graph["31"] = _encode(OTHER)
graph["15"]["inputs"]["any_01"] = ["30", 0]
result = _inspect(graph)
found = {item["sampler_id"]: _texts(item) for item in result["candidates"]}
self.assertEqual(found["17"], (POSITIVE, ""))
class TestLegacyShapedRules(unittest.TestCase):
def test_a_text_input_is_the_text(self):
graph = _with(
any_switch_graph(any_01=["20", 0]),
**{
"20": {
"class_type": "VendorTextEncode",
"inputs": {"text": OTHER, "clip": ["9", 1]},
}
},
)
self.assertEqual(_texts(_inspect(graph))[0], OTHER)
def test_role_inputs_route_by_output_slot(self):
graph = any_switch_graph()
graph["20"] = {
"class_type": "VendorControlApply",
"inputs": {
"positive": ["21", 0],
"negative": ["22", 0],
"control_net": ["8", 0],
"image": ["8", 0],
},
}
graph["21"] = _encode(POSITIVE)
graph["22"] = _encode(NEGATIVE)
graph["17"]["inputs"]["positive"] = ["20", 0]
graph["17"]["inputs"]["negative"] = ["20", 1]
self.assertEqual(_texts(_inspect(graph)), (POSITIVE, NEGATIVE))
def test_a_conditioning_input_is_followed_alone(self):
graph = _with(
any_switch_graph(any_01=["20", 0]),
**{
"20": {
"class_type": "VendorStyleApply",
"inputs": {"conditioning": ["10", 0], "style": ["21", 0]},
},
"21": _encode(OTHER),
},
)
self.assertEqual(_texts(_inspect(graph)), (POSITIVE, ""))
def test_core_type_inputs_are_skipped(self):
# The vendor encoder's ``clip`` reaches a loader with its own ``text``:
# that text is a lora tag, never the prompt.
graph = _with(
any_switch_graph(any_01=["20", 0]),
**{
"20": {
"class_type": "VendorPromptEncode",
"inputs": {"prompt": ["21", 0], "clip": ["22", 1]},
},
"21": {"class_type": "PrimitiveString", "inputs": {"value": OTHER}},
"22": {
"class_type": "VendorLoraLoader",
"inputs": {"text": "<lora:x:1>", "clip": ["7", 0]},
},
},
)
result = _inspect(graph)
self.assertEqual(result["state"], "resolved")
self.assertEqual(_texts(result)[0], OTHER)
class TestBounds(unittest.TestCase):
def test_a_long_unknown_chain_reaches_the_depth_limit(self):
graph = any_switch_graph(any_01=["100", 0])
for index in range(100, 100 + resolver.INSPECTION_PROFILE.max_depth + 2):
graph[str(index)] = {
"class_type": "Router Node (vendor)",
"inputs": {"in": [str(index + 1), 0]},
}
graph[str(index + 1)] = _pair()
result = _inspect(graph)
self.assertEqual(result["state"], "unresolved")
self.assertEqual(result["reason_code"], "depth-limit")
def test_many_different_sources_hit_the_candidate_cap(self):
inputs = {}
graph = any_switch_graph()
for index in range(resolver._MAX_BRANCH_CANDIDATES + 1):
graph[str(200 + index)] = _encode(f"{OTHER} {index}")
inputs[f"any_{index:02}"] = [str(200 + index), 0]
graph["15"]["inputs"] = inputs
result = _inspect(graph)
self.assertEqual(result["state"], "unresolved")
self.assertEqual(result["reason_code"], "work-limit")
if __name__ == "__main__":
unittest.main()
class TestGeneratedAndTransformedText(unittest.TestCase):
def test_a_seeded_text_node_is_generated_at_run_time(self):
result = _inspect(graphs.generated_text_graph())
self.assertEqual(result["state"], "unresolved")
self.assertEqual(result["reason_code"], "runtime-value")
self.assertEqual(
result["node"], {"id": "30", "class_type": "VendorTextGenerate"}
)
def test_the_recorded_text_is_authoritative(self):
# The record is what ran: a generated or transformed text is never
# checked against the generator's or transform's inputs.
for name, records in graphs.recorded_shapes().items():
with self.subTest(shape=name):
metadata = {
"prompt": json.loads(records["prompt"]),
"comfyassets_prompt_pairs": records["comfyassets_prompt_pairs"],
}
result = resolve_prompt_pair(
metadata, profile=resolver.INSPECTION_PROFILE
)
self.assertEqual(result["state"], "resolved", result["reason"])
self.assertEqual(_texts(result), (graphs.GENERATED, ""))
self.assertEqual(
result["positive"]["provenance"]["text_source"], "execution"
)
def test_a_seeded_conditioning_node_still_passes_through(self):
graph = _with(
any_switch_graph(any_01=["20", 0]),
**{
"20": {
"class_type": "VendorConditioningNoise",
"inputs": {"conditioning": ["10", 0], "seed": 3},
}
},
)
self.assertEqual(_texts(_inspect(graph)), (POSITIVE, ""))
class TestReviewFindings(unittest.TestCase):
"""Shapes from the independent review of the first #381 commit."""
def _recorded(self, text_source: dict) -> dict:
graph = any_switch_graph()
graph["10"]["inputs"]["text"] = ["30", 0]
graph.update(text_source)
metadata = {
"prompt": graph,
"comfyassets_prompt_pairs": graphs.execution("10", graphs.GENERATED),
}
return resolve_prompt_pair(metadata, profile=resolver.INSPECTION_PROFILE)
def test_a_failing_walk_never_overrides_the_recorded_text(self):
sources = {
"missing": {"30": {"class_type": "XLLM", "inputs": {"src": ["99", 0]}}},
"composite": {
"30": {"class_type": "XLLM", "inputs": {"src": ["31", 0]}},
"31": {
"class_type": "ConditioningCombine",
"inputs": {
"conditioning_1": ["32", 0],
"conditioning_2": ["33", 0],
},
},
"32": _encode(POSITIVE),
"33": _encode(OTHER),
},
"several": {
"30": {
"class_type": "XLLM",
"inputs": {"a": ["31", 0], "b": ["32", 0]},
},
"31": {"class_type": "PrimitiveString", "inputs": {"value": POSITIVE}},
"32": {"class_type": "PrimitiveString", "inputs": {"value": OTHER}},
},
"list-text": {"30": {"class_type": "XLLM", "inputs": {"text": [1, 2, 3]}}},
}
for name, source in sources.items():
with self.subTest(source=name):
result = self._recorded(source)
self.assertEqual(result["state"], "resolved", result["reason"])
self.assertEqual(_texts(result)[0], graphs.GENERATED)
def test_the_seed_rule_reads_only_string_outputs(self):
graph = _with(
any_switch_graph(any_01=["20", 0]),
**{
"20": {"class_type": "XNoise", "inputs": {"conditioning": ["21", 0]}},
"21": {
"class_type": "XSeedEncode",
"inputs": {"clip": ["7", 0], "text": OTHER, "seed": 3},
},
},
)
self.assertEqual(_texts(_inspect(graph))[0], OTHER)
def test_an_unlinked_role_input_adds_nothing(self):
graph = any_switch_graph()
graph["20"] = {
"class_type": "XApply",
"inputs": {"positive": ["21", 0], "negative_opt": ""},
}
graph["21"] = _encode(POSITIVE)
graph["17"]["inputs"]["positive"] = ["20", 0]
graph["17"]["inputs"]["negative"] = ["20", 1]
result = _inspect(graph)
self.assertEqual(result["state"], "unresolved")
self.assertEqual(result["reason_code"], "unsupported-node")
self.assertEqual(result["node"], {"id": "20", "class_type": "XApply"})
def test_one_text_offered_twice_is_one_candidate(self):
graph = _with(
any_switch_graph(any_01=["20", 0], any_02=["23", 0]),
**{
"20": {"class_type": "XB", "inputs": {"p": ["21", 0], "q": ["22", 0]}},
"21": _encode(POSITIVE),
"22": _encode(OTHER),
"23": _encode(POSITIVE),
},
)
result = _inspect(graph)
self.assertEqual(result["state"], "ambiguous")
self.assertEqual(
[_texts(item)[0] for item in result["candidates"]], [POSITIVE, OTHER]
)