* 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.
392 lines
16 KiB
Python
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]
|
|
)
|