Files
ComfyAssets-ComfyUI_PromptM…/tests/local_resolver_cases.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

1468 lines
46 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Record sets for the local resolver parity test (#249 local Inspect, PR L3 Task 8).
Every case is a dict of record texts as ``read_inspection_file`` returns them
(``prompt``, ``workflow``, ``comfyassets_prompt_pairs``), so the server's
``inspect_prompt_evidence`` and the browser's ``resolveLocalEvidence`` read the
same bytes. ``golden_cases`` names one case per traced strategy, bound and
malformed shape; ``fuzz_cases`` builds coherent single-sampler graphs from the
same node vocabulary, then corrupts some of them. All content is synthetic.
``single_chain`` says, from the server's own sampler table, whether a record
set resolves without candidate selection (the Task 8 subset);
``local_resolver_selection_cases`` holds the candidate-selection cases.
"""
import argparse
import json
import random
from pathlib import Path
from utils.inspection_semantics import get_semantics
from utils.prompt_inspector import inspect_prompt_evidence
from utils.prompt_pair_resolver import INSPECTION_PROFILE
VECTORS_PATH = (
Path(__file__).resolve().parent
/ "fixtures"
/ "local-inspect"
/ "resolver-vectors.json"
)
# A case may name the inspected file (#381 saver anchoring); it is no record.
FILE_NAME = "__file_name"
# Committed record sets stay small; the large bound cases are built at test time.
MAX_VECTOR_CHARS = 20_000
COMMITTED_FUZZ = (0, 60)
COMMITTED_PASSTHROUGH = (0, 40)
SPACES = ["", " ", "\t", "\n", "\x1c", "\x85", " ", "", "
 "]
WORDS = ["fox", "snow", "café", "猫", "\U0001f600", "a,", "detailed fur", ""]
def _dump(graph, ensure_ascii=False):
return json.dumps(graph, ensure_ascii=ensure_ascii)
def _encode(text, name="CLIPTextEncode"):
return {"class_type": name, "inputs": {"text": text, "clip": ["4", 1]}}
def _graph(positive, negative, extra=None, sampler=None):
graph = {
"3": sampler
or {
"class_type": "KSampler",
"inputs": {
"seed": 1,
"model": ["4", 0],
"positive": positive,
"negative": negative,
"latent_image": ["5", 0],
},
},
"4": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "m"}},
"5": {"class_type": "EmptyLatentImage", "inputs": {"width": 64}},
}
graph.update(extra or {})
return graph
def _pair(text="fox", negative="blur", prepend="", append=""):
return {
"class_type": "PromptManagerPair",
"inputs": {
"text": text,
"negative_text": negative,
"prepend_text": prepend,
"append_text": append,
},
}
def _execution(node_id, **changes):
record = {
"text": "fox",
"negative_text": "blur",
"positive_text": "fox executed",
"negative_effective_text": "blur",
"prepend_text": "",
"append_text": "",
}
record.update(changes)
return json.dumps({"version": 1, "nodes": {node_id: {"records": [record]}}})
def _chain(length, start="6"):
nodes = {start: _encode("deep")}
previous = start
for index in range(length):
node_id = f"r{index}"
nodes[node_id] = {"class_type": "Reroute", "inputs": {"value": [previous, 0]}}
previous = node_id
return nodes, previous
def _combine_tree(levels):
"""A full ConditioningCombine tree over equal encoders: many distinct visits."""
nodes, layer = {}, []
for index in range(2**levels):
nodes[f"e{index}"] = _encode("same")
layer.append(f"e{index}")
level = 0
while len(layer) > 1:
nxt = []
for index in range(0, len(layer), 2):
node_id = f"c{level}_{index}"
nodes[node_id] = {
"class_type": "ConditioningCombine",
"inputs": {
"conditioning_1": [layer[index], 0],
"conditioning_2": [layer[index + 1], 0],
},
}
nxt.append(node_id)
layer, level = nxt, level + 1
return nodes, layer[0]
def golden_cases():
"""Named record sets, one per strategy, bound and malformed shape."""
cases = {}
def add(name, graph=None, prompt=None, **records):
cases[name] = {"prompt": prompt if prompt is not None else _dump(graph)}
cases[name].update(records)
neg = ["7", 0]
base = {"6": _encode("a fox"), "7": _encode("blurry")}
add("encode", _graph(["6", 0], neg, base))
add("empty-positive", _graph(["6", 0], neg, {**base, "6": _encode(" \x85")}))
add("feff-positive", _graph(["6", 0], neg, {**base, "6": _encode("")}))
add("no-negative", _graph(["6", 0], None, base))
add(
"negative-missing-input",
None,
prompt=_dump(_graph(["6", 0], neg, base)).replace('"negative": ["7", 0], ', ""),
)
add(
"float-slot",
None,
prompt=_dump(_graph(["6", 0], neg, base)).replace('["6", 0]', '["6", 0.0]'),
)
add(
"bool-slot",
None,
prompt=_dump(_graph(["6", 0], neg, base)).replace('["6", 0]', '["6", true]'),
)
add("int-id", None, prompt=_dump(_graph([6, 0], neg, base)))
add("dangling", _graph(["60", 0], neg, base))
add("wrong-slot", _graph(["6", 3], neg, base))
add(
"cycle",
_graph(
["8", 0],
neg,
{**base, "8": {"class_type": "Reroute", "inputs": {"value": ["8", 0]}}},
),
)
add(
"reroute-ambiguous",
_graph(
["8", 0],
neg,
{
**base,
"8": {
"class_type": "Reroute",
"inputs": {"value": ["6", 0], "in": ["6", 0]},
},
},
),
)
add(
"duplicate-key",
None,
prompt=_dump(_graph(["6", 0], neg, base))[:-1]
+ ', "6": '
+ _dump(_encode("last wins"))
+ "}",
)
add(
"nan-input",
None,
prompt=_dump(_graph(["6", 0], neg, base)).replace('"seed": 1', '"seed": NaN'),
)
add(
"class-type-number",
_graph(
["6", 0], neg, {**base, "6": {"class_type": 1.5, "inputs": {"text": "x"}}}
),
)
add(
"class-type-missing",
_graph(["6", 0], neg, {**base, "6": {"inputs": {"text": "x"}}}),
)
add(
"inputs-list",
_graph(
["6", 0],
neg,
{**base, "6": {"class_type": "CLIPTextEncode", "inputs": ["x"]}},
),
)
add("prompt-list", None, prompt="[1, 2]")
add("prompt-empty", None, prompt="{}")
add("prompt-bom", None, prompt="" + _dump(_graph(["6", 0], neg, base)))
add(
"huge-int",
None,
prompt=_dump(_graph(["6", 0], neg, base)).replace(
'"seed": 1', '"seed": ' + "9" * 4301
),
)
add("only-execution", None, prompt=None)
cases["only-execution"] = {"comfyassets_prompt_pairs": _execution("8")}
add(
"no-sampler",
{
"1": _encode("x"),
"2": {"class_type": "XSampler", "inputs": {"positive": ["1", 0]}},
"9": {"class_type": "YGuider", "inputs": {"guider": ["1", 0]}},
},
)
add("no-sampler-plain", {"1": _encode("x")})
sdxl = {
"class_type": "CLIPTextEncodeSDXL",
"inputs": {"text_g": "a", "text_l": "a"},
}
add("sdxl-equal", _graph(["8", 0], neg, {**base, "8": sdxl}))
add(
"sdxl-one",
_graph(
["8", 0],
neg,
{
**base,
"8": {
"class_type": "CLIPTextEncodeSDXL",
"inputs": {"text_g": "a", "text_l": ""},
},
},
),
)
add(
"sdxl-differ",
_graph(
["8", 0],
neg,
{
**base,
"8": {
"class_type": "CLIPTextEncodeSDXL",
"inputs": {"text_g": "a", "text_l": "b"},
},
},
),
)
for flag in (False, True, 1, ["9", 0]):
smz = {
"class_type": "smZ CLIPTextEncode",
"inputs": {"text": "t", "text_g": "g", "text_l": "g", "with_SDXL": flag},
}
add(f"smz-{json.dumps(flag)}", _graph(["8", 0], neg, {**base, "8": smz}))
add(
"zeroed",
_graph(
["6", 0],
["8", 0],
{
**base,
"8": {
"class_type": "ConditioningZeroOut",
"inputs": {"conditioning": ["6", 0]},
},
},
),
)
add(
"passthrough",
_graph(
["8", 0],
neg,
{
**base,
"8": {
"class_type": "FluxGuidance",
"inputs": {"conditioning": ["6", 0], "guidance": 3.5},
},
},
),
)
add(
"controlnet-slot1",
_graph(
["8", 0],
["8", 1],
{
**base,
"8": {
"class_type": "ControlNetApplyAdvanced",
"inputs": {"positive": ["6", 0], "negative": ["7", 0]},
},
},
),
)
add(
"combine-equal",
_graph(
["8", 0],
neg,
{
**base,
"9": _encode("a fox"),
"8": {
"class_type": "ConditioningCombine",
"inputs": {"conditioning_1": ["6", 0], "conditioning_2": ["9", 0]},
},
},
),
)
add(
"combine-differ",
_graph(
["8", 0],
neg,
{
**base,
"8": {
"class_type": "ConditioningCombine",
"inputs": {"conditioning_1": ["6", 0], "conditioning_2": ["7", 0]},
},
},
),
)
for condition in (True, False, ["9", 0], 1, "true"):
select = {
"class_type": "easy ifElse",
"inputs": {"boolean": condition, "on_true": ["6", 0], "on_false": ["7", 0]},
}
add(
f"select-{json.dumps(condition)}",
_graph(
["8", 0],
["10", 0],
{
**base,
"8": select,
"10": _encode("neg"),
"9": {"class_type": "PrimitiveBoolean", "inputs": {"value": True}},
},
),
)
same = {
"class_type": "easy ifElse",
"inputs": {"boolean": ["9", 0], "on_true": ["6", 0], "on_false": ["11", 0]},
}
add(
"select-converge",
_graph(["8", 0], neg, {**base, "8": same, "11": _encode("a fox")}),
)
# Two selectors on one condition: forced together, so two candidates, not four.
add(
"select-shared",
_graph(
["8", 0],
["12", 0],
{
**base,
"8": {
"class_type": "easy ifElse",
"inputs": {
"boolean": ["9", 0],
"on_true": ["6", 0],
"on_false": ["7", 0],
},
},
"12": {
"class_type": "Switch conditioning [Crystools]",
"inputs": {
"boolean": ["9", 0],
"on_true": ["7", 0],
"on_false": ["6", 0],
},
},
},
),
)
add(
"runtime",
_graph(
["8", 0],
neg,
{**base, "8": {"class_type": "PromptSearchList", "inputs": {}}},
),
)
add(
"unknown-encoder",
_graph(
["8", 0],
neg,
{
**base,
"8": {"class_type": "FancyEncoder" + "x" * 80, "inputs": {"text": "x"}},
},
),
)
add(
"primitive",
_graph(
["6", 0],
neg,
{
**base,
"6": _encode(["8", 0]),
"8": {
"class_type": "PrimitiveStringMultiline",
"inputs": {"value": " multi\nline "},
},
},
),
)
add(
"string-kind-mismatch",
_graph(
["8", 0],
neg,
{**base, "8": {"class_type": "PrimitiveString", "inputs": {"value": "x"}}},
),
)
concat = {
"class_type": "StringConcatenate",
"inputs": {"string_a": " a ", "string_b": "", "delimiter": ", "},
}
add(
"string-concatenate",
_graph(["6", 0], neg, {**base, "6": _encode(["8", 0]), "8": concat}),
)
for clean in ("true", "False", True, "TRUE", 0):
text_concat = {
"class_type": "Text Concatenate",
"inputs": {
"text_b": " b ",
"text_a": " a",
"text_aa": "x",
"text_c": "",
"clean_whitespace": clean,
},
}
add(
f"text-concatenate-{json.dumps(clean)}",
_graph(["6", 0], neg, {**base, "6": _encode(["8", 0]), "8": text_concat}),
)
for strip in (True, False, 1):
multiline = {
"class_type": "StringConstantMultiline",
"inputs": {"string": "\n a\nb \n", "strip_newlines": strip},
}
add(
f"strip-newlines-{json.dumps(strip)}",
_graph(["6", 0], neg, {**base, "6": _encode(["8", 0]), "8": multiline}),
)
for count, as_list in (
(3, False),
(1, False),
(0, False),
(1001, False),
(2, True),
(2.0, False),
(True, False),
):
join = {
"class_type": "JoinStringMulti",
"inputs": {
"string_1": "",
"string_2": "b",
"string_3": "",
"string_4": "d",
"inputcount": count,
"delimiter": " | ",
"return_list": as_list,
},
}
add(
f"join-multi-{json.dumps(count)}-{json.dumps(as_list)}",
_graph(["6", 0], neg, {**base, "6": _encode(["8", 0]), "8": join}),
)
manager = {
"class_type": "PromptManager",
"inputs": {
"text": "main",
"prepend_text": " before ",
"append_text": "",
"clip": ["4", 1],
},
}
add("prompt-manager", _graph(["8", 0], neg, {**base, "8": manager}))
add(
"prompt-manager-null-prepend",
_graph(
["8", 0],
neg,
{
**base,
"8": {**manager, "inputs": {**manager["inputs"], "prepend_text": None}},
},
),
)
for name, slot in (
("PromptManagerV2", 0),
("PromptManagerV2", 1),
("PromptManagerPositive", 0),
("PromptManagerNegative", 0),
):
historical = {
"class_type": name,
"inputs": {
"text": " main ",
"negative_text": " neg ",
"prepend_text": "lead,",
"append_text": None,
},
}
add(
f"historical-{name}-{slot}",
_graph(["8", slot], neg, {**base, "8": historical}),
)
pair_graph = _graph(["8", 0], ["8", 1], {**base, "8": _pair()})
add("pair-graph", pair_graph)
add("pair-execution", pair_graph, comfyassets_prompt_pairs=_execution("8"))
add(
"pair-execution-other-node",
pair_graph,
comfyassets_prompt_pairs=_execution("99"),
)
add(
"pair-execution-mismatch",
pair_graph,
comfyassets_prompt_pairs=_execution("8", text="other"),
)
add(
"pair-execution-negative-changed",
pair_graph,
comfyassets_prompt_pairs=_execution("8", negative_effective_text="x"),
)
add(
"pair-execution-version-true",
pair_graph,
comfyassets_prompt_pairs=_execution("8").replace(
'"version": 1', '"version": true'
),
)
add(
"pair-execution-version-float",
pair_graph,
comfyassets_prompt_pairs=_execution("8").replace(
'"version": 1', '"version": 1.0'
),
)
add(
"pair-execution-truncated",
pair_graph,
comfyassets_prompt_pairs=_execution("8").replace(
'"version": 1', '"version": 1, "truncated": []'
),
)
add("pair-execution-bad-json", pair_graph, comfyassets_prompt_pairs="{nope")
two = json.loads(_execution("8"))
two["nodes"]["8"]["records"].append(
dict(two["nodes"]["8"]["records"][0], positive_text="other")
)
add(
"pair-execution-two-records",
pair_graph,
comfyassets_prompt_pairs=json.dumps(two),
)
linked_pair = _graph(
["8", 0],
["8", 1],
{
**base,
"8": {**_pair(), "inputs": {**_pair()["inputs"], "text": ["9", 0]}},
"9": {"class_type": "PromptSearchList", "inputs": {}},
},
)
add(
"pair-execution-runtime-input",
linked_pair,
comfyassets_prompt_pairs=_execution("8"),
)
add(
"pair-execution-runtime-wrong-slot",
_graph(
["8", 0],
["8", 1],
{
**base,
"8": {**_pair(), "inputs": {**_pair()["inputs"], "text": ["9", 2]}},
"9": {"class_type": "PromptManagerText", "inputs": {"text": "x"}},
},
),
comfyassets_prompt_pairs=_execution("8"),
)
guided = {
"class_type": "SamplerCustomAdvanced",
"inputs": {
"noise": ["20", 0],
"guider": ["21", 0],
"sampler": ["22", 0],
"sigmas": ["23", 0],
"latent_image": ["5", 0],
},
}
add(
"custom-cfg",
_graph(
None,
None,
{
**base,
"21": {
"class_type": "CFGGuider",
"inputs": {
"positive": ["6", 0],
"negative": ["7", 0],
"model": ["4", 0],
"cfg": 5,
},
},
},
sampler=guided,
),
)
add(
"custom-basic",
_graph(
None,
None,
{
**base,
"21": {
"class_type": "BasicGuider",
"inputs": {"conditioning": ["6", 0], "model": ["4", 0]},
},
},
sampler=guided,
),
)
add(
"custom-reroute-guider",
_graph(
None,
None,
{
**base,
"21": {"class_type": "Reroute", "inputs": {"value": ["24", 0]}},
"24": {
"class_type": "BasicGuider",
"inputs": {"conditioning": ["6", 0]},
},
},
sampler=guided,
),
)
add(
"custom-unknown-guider",
_graph(
None,
None,
{
**base,
"21": {"class_type": "DualCFGGuider", "inputs": {"cond1": ["6", 0]}},
},
sampler=guided,
),
)
add(
"latent-stage",
_graph(
["6", 0],
neg,
{
**base,
"5": {"class_type": "LatentUpscale", "inputs": {"samples": ["30", 0]}},
"30": {
"class_type": "KSamplerPreview",
"inputs": {"model": ["4", 0], "positive": ["6", 0]},
},
},
),
)
add(
"latent-encode",
_graph(
["6", 0],
neg,
{
**base,
"5": {
"class_type": "VAEEncode",
"inputs": {"pixels": ["31", 0], "vae": ["4", 2]},
},
"31": {"class_type": "LoadImage", "inputs": {"image": "x.png"}},
},
),
)
add(
"latent-cycle",
_graph(
["6", 0],
neg,
{
**base,
"5": {"class_type": "LatentUpscale", "inputs": {"samples": ["3", 0]}},
},
),
)
for override in ("", "wild", ["6", 0]):
detail = {
"class_type": "KSampler",
"inputs": {
"positive": ["6", 0],
"negative": neg,
"latent_image": ["5", 0],
"wildcard": override,
},
}
add(
f"override-ignored-{json.dumps(override)}",
_graph(None, None, base, sampler=detail),
)
chain, end = _chain(70)
add("depth-limit", _graph([end, 0], neg, {**base, **chain}))
chain, end = _chain(61)
add("depth-near", _graph([end, 0], neg, {**base, **chain}))
tree, top = _combine_tree(11)
add("work-limit-visits", _graph([top, 0], neg, {**base, **tree}))
tree, top = _combine_tree(9)
add("diamond-memo", _graph([top, 0], [top, 0], {**base, **tree}))
add("text-limit", _graph(["6", 0], neg, {**base, "6": _encode("x" * 100_001)}))
add(
"text-at-limit",
_graph(["6", 0], neg, {**base, "6": _encode("\U0001f600" * 100_000)}),
)
big = {
f"s{index}": {
"class_type": "PrimitiveString",
"inputs": {"value": "y" * 99_000},
}
for index in range(20)
}
joined = {
"class_type": "JoinStringMulti",
"inputs": {
"string_1": ["s0", 0],
"inputcount": 20,
"delimiter": "",
"return_list": False,
},
}
joined["inputs"].update(
{f"string_{index + 1}": [f"s{index}", 0] for index in range(1, 20)}
)
add(
"join-over-length",
_graph(["6", 0], neg, {**base, **big, "6": _encode(["8", 0]), "8": joined}),
)
concats = {
f"k{index}": {
"class_type": "StringConcatenate",
"inputs": {"string_a": "z" * 90_000, "string_b": "", "delimiter": ""},
}
for index in range(20)
}
combine = {f"q{index}": _encode([f"k{index}", 0]) for index in range(20)}
add("built-chars", _graph(["q0", 0], ["q1", 0], {**base, **concats, **combine}))
ceiling = {
str(index): {"class_type": "Note", "inputs": {}} for index in range(100, 20_102)
}
add("graph-ceiling", _graph(["6", 0], neg, {**base, **ceiling}))
workflow = {
"nodes": [
{"id": 6, "type": "PromptManager", "widgets_values": ["own text"]},
{"id": 7, "type": "PromptManager", "widgets_values": ["other text"]},
]
}
overwritten = {**base, "6": _encode("shared"), "7": _encode("shared")}
overwritten["6"]["inputs"]["search_text"] = ""
add(
"overwritten",
_graph(["6", 0], ["7", 0], overwritten),
workflow=json.dumps(workflow),
)
nested = {
"nodes": [{"id": 1, "type": "sub-a", "widgets_values": []}],
"definitions": {
"subgraphs": [
{
"id": "sub-a",
"nodes": [
{
"id": 6,
"type": "PromptManagerFocused",
"widgets_values": {"text": "a"},
},
{
"id": 7,
"type": "PromptManagerFocused",
"widgets_values": ["b"],
},
],
}
]
},
}
nested_graph = {
**base,
"1:6": {
"class_type": "PromptManager",
"inputs": {"text": "same", "clip": ["4", 1]},
},
"1:7": {"class_type": "PromptManager", "inputs": {"text": "same"}},
}
add(
"overwritten-subgraph",
_graph(["1:6", 0], ["1:7", 0], nested_graph),
workflow=json.dumps(nested),
)
add(
"overwritten-bad-workflow",
_graph(["6", 0], ["7", 0], overwritten),
workflow="{bad",
)
# A list or object node ``type`` is skipped like a non-dict node (#286), also
# inside a subgraph; a skipped node never hides the rest of the walk.
for label, kind in (("list", ["PromptManager"]), ("dict", {"a": 1})):
add(
f"workflow-type-{label}",
_graph(["6", 0], ["7", 0], overwritten),
workflow=json.dumps(
{"nodes": [{"id": 6, "type": "PromptManager"}, {"id": 8, "type": kind}]}
),
)
add(
"workflow-type-in-subgraph",
_graph(["1:6", 0], ["1:7", 0], nested_graph),
workflow=json.dumps(
{
**nested,
"definitions": {
"subgraphs": [{"id": "sub-a", "nodes": [{"id": 2, "type": [1]}]}]
},
}
),
)
add(
"workflow-type-skipped-keeps-overwritten",
_graph(["6", 0], ["7", 0], overwritten),
workflow=json.dumps(
{
"nodes": [
workflow["nodes"][0],
{"id": 8, "type": ["PromptManager"]},
workflow["nodes"][1],
]
}
),
)
add(
"workflow-type-without-id",
_graph(["6", 0], ["7", 0], overwritten),
workflow=json.dumps({"nodes": [{"id": True, "type": ["x"]}, "skipped"]}),
)
add(
"several-samplers",
_graph(
["6", 0],
neg,
{
**base,
"9": {
"class_type": "KSampler",
"inputs": {"positive": ["6", 0], "negative": neg},
},
},
),
)
add(
"detailer",
_graph(
None,
None,
base,
sampler={
"class_type": "FaceDetailer",
"inputs": {
"image": ["5", 0],
"positive": ["6", 0],
"negative": neg,
"wildcard": "",
},
},
),
)
# #381: unknown nodes between the prompt and the sampler pass through.
try:
from tests import unknown_node_graphs
except ImportError: # pragma: no cover - discover -s tests puts tests/ first
import unknown_node_graphs
for name, graph in unknown_node_graphs.shapes().items():
add(f"pass-through-{name}", graph)
for name, records in {
**unknown_node_graphs.recorded_shapes(),
**unknown_node_graphs.review_shapes(),
}.items():
cases[f"pass-through-{name}"] = records
try:
from tests import saver_anchor_graphs as anchor
except ImportError: # pragma: no cover - discover -s tests puts tests/ first
import saver_anchor_graphs as anchor
for name, graph, file_name in [
("branch-3", anchor.branch_bench(), "s45_branch_3_00002_.png"),
("branch-unanchored", anchor.branch_bench(), None),
("branch-no-match", anchor.branch_bench(), "photo_00001_.png"),
("branch-linked", anchor.linked_prefix_bench(), "s45_branch_2_00007_.png"),
(
"branch-unknown-saver",
anchor.branch_bench(saver="VendorSaveImage"),
"s45_branch_4_00001.webp",
),
("shared-prefix", anchor.shared_prefix(), "IMG_GEN_00371_.png"),
("same-links", anchor.two_stage_same_conditioning(), "final_00003_.png"),
("same-links-unanchored", anchor.two_stage_same_conditioning(), None),
(
"other-links",
anchor.two_stage_same_conditioning(["91", 0]),
"final_00003_.png",
),
("hidden-earlier", anchor.hidden_earlier_prompt(), "out_00001_.png"),
(
"hidden-override",
anchor.hidden_earlier_prompt(override=True),
"out_00001_.png",
),
] + [
(f"first-stage-{kind}", anchor.first_stage_variant(kind), "final_00003_.png")
for kind in (
"no-latent",
"literal-latent",
"missing-latent",
"cyclic-latent",
"stage-shaped",
"detailer",
)
]:
cases[f"anchor-{name}"] = {"prompt": _dump(graph)}
if file_name is not None:
cases[f"anchor-{name}"][FILE_NAME] = file_name
cases["parameters-only"] = {
"parameters": "a fox\nNegative prompt: blur\nSteps: 20, Sampler: euler"
}
cases["prompt-and-parameters"] = {
"prompt": _dump(_graph(["6", 0], neg, base)),
"parameters": "ignored",
}
return cases
def _visits_graph(count):
"""``count`` PrimitiveString links joined under one encoder (``count`` visits)."""
first, second = count // 2, count - count // 2
nodes = {
f"p{index}": {"class_type": "PrimitiveString", "inputs": {"value": "w"}}
for index in range(count)
}
for name, start, size in (("j1", 0, first), ("j2", first, second)):
inputs = {"inputcount": max(size, 1), "delimiter": "", "return_list": False}
inputs.update(
{f"string_{index + 1}": [f"p{start + index}", 0] for index in range(size)}
)
inputs.setdefault("string_1", "")
nodes[name] = {"class_type": "JoinStringMulti", "inputs": inputs}
nodes["k"] = {
"class_type": "StringConcatenate",
"inputs": {"string_a": ["j1", 0], "string_b": ["j2", 0], "delimiter": ""},
}
nodes["6"] = _encode(["k", 0])
nodes["7"] = _encode("blurry")
return {"prompt": _dump(_graph(["6", 0], ["7", 0], nodes))}
def _depth_graph(length):
chain, end = _chain(length)
return {
"prompt": _dump(_graph([end, 0], ["7", 0], {**chain, "7": _encode("blurry")}))
}
def _scan_graph(tail):
"""Whitespace-only Text Concatenate parts: scanned before stripping, never built."""
nodes, parts = {}, []
for index in range(4):
if index < 3:
inputs = {
f"text_{letter}": " " * 99_000 for letter in "abcdefghijklmnopqrstu"
}
else:
inputs, left = {}, tail
for letter in "abcdefghijklmnopqrstu":
inputs[f"text_{letter}"] = " " * min(99_000, left)
left -= min(99_000, left)
inputs["text_z"] = "kept" if index == 0 else ""
nodes[f"t{index}"] = {"class_type": "Text Concatenate", "inputs": inputs}
parts.append([f"t{index}", 0])
nodes["k"] = {
"class_type": "JoinStringMulti",
"inputs": {
"string_1": parts[0],
"string_2": parts[1],
"string_3": parts[2],
"string_4": parts[3],
"inputcount": 4,
"delimiter": "",
"return_list": False,
},
}
nodes["6"] = _encode(["k", 0])
nodes["7"] = _encode("blurry")
return {"prompt": _dump(_graph(["6", 0], ["7", 0], nodes))}
def _threshold(build, low, high):
"""The largest parameter in [low, high) the server still resolves."""
def resolved(value):
evidence = inspect_prompt_evidence(
build(value), None, profile=INSPECTION_PROFILE
)
return evidence["resolution"]["state"] == "resolved"
assert resolved(low) and not resolved(high)
while high - low > 1:
middle = (low + high) // 2
if resolved(middle):
low = middle
else:
high = middle
return low
# The nesting the browser's CPython-JSON parsers accept (py-json.mjs JSON_MAX_DEPTH).
JSON_MAX_DEPTH = 1000
JSON_DEPTHS = (JSON_MAX_DEPTH - 1, JSON_MAX_DEPTH, JSON_MAX_DEPTH + 1)
def _nested(depth):
"""``depth`` nested arrays."""
return "[" * depth + "]" * depth
def depth_cases():
"""``(name, kind, depth, records)``: a deep record at the JSON depth cap.
A ``prompt`` holds a junk input nested to ``depth`` containers in all (the
graph, its node and the inputs are three); parsed, both roles resolve.
A ``workflow`` nested to ``depth`` makes two PromptManager nodes
overwritten (R1) when it is read, and the prompt resolves when it is not.
"""
cases = []
base = {"6": _encode("a fox"), "7": _encode("blurry")}
for depth in JSON_DEPTHS:
graph = _dump(_graph(["6", 0], ["7", 0], {**base, "9": {"class_type": "Note"}}))
junk = '"inputs": {"junk": ' + _nested(depth - 3) + "}"
prompt = graph.replace('"class_type": "Note"', '"class_type": "Note", ' + junk)
cases.append((f"prompt-depth-{depth}", "prompt", depth, {"prompt": prompt}))
overwritten = {**base, "6": _encode("shared"), "7": _encode("shared")}
overwritten["6"]["inputs"]["search_text"] = ""
nodes = [
{"id": 6, "type": "PromptManager", "widgets_values": ["own text"]},
{"id": 7, "type": "PromptManager", "widgets_values": ["other text"]},
]
workflow = json.dumps({"nodes": nodes, "extra": 0}).replace(
'"extra": 0', '"extra": ' + _nested(depth - 1)
)
records = {
"prompt": _dump(_graph(["6", 0], ["7", 0], overwritten)),
"workflow": workflow,
}
cases.append((f"workflow-depth-{depth}", "workflow", depth, records))
return cases
def boundary_cases():
"""Each work bound at its edge: the last value that resolves, then the next."""
cases = {}
for name, build, low, high in (
("visits", _visits_graph, 10, 2000),
("depth", _depth_graph, 1, 80),
("scanned", _scan_graph, 0, 2_000_000),
):
edge = _threshold(build, low, high)
cases[f"{name}-edge"] = build(edge)
cases[f"{name}-over"] = build(edge + 1)
long_name = "\U0001f600" * 70
cases["named-astral"] = {
"prompt": _dump(
_graph(
["\U0001f600" * 70, 0],
["7", 0],
{
"\U0001f600"
# No text and no links: still named, not passed through (#381).
* 70: {"class_type": long_name, "inputs": {"seed": 1}},
"7": _encode("b"),
},
)
)
}
return cases
class _Builder:
"""Coherent random graphs over the traced vocabulary, sometimes corrupted."""
def __init__(self, rng):
self.rng = rng
self.nodes = {}
self.records = {}
self.counter = 100
def new_id(self):
self.counter += 1
return str(self.counter)
def word(self):
rng = self.rng
parts = [rng.choice(SPACES), rng.choice(WORDS), rng.choice(SPACES)]
if rng.random() < 0.3:
parts.append(rng.choice(WORDS))
return "".join(parts)
def string(self, depth):
"""A string input: a literal, or a link to a string node."""
rng = self.rng
if depth > 3 or rng.random() < 0.45:
return self.word()
node_id, choice = self.new_id(), rng.randrange(8)
if choice == 0:
node = {
"class_type": rng.choice(
["PrimitiveString", "PrimitiveStringMultiline"]
),
"inputs": {"value": self.string(depth + 1)},
}
elif choice == 1:
node = {
"class_type": "StringConcatenate",
"inputs": {
"string_a": self.string(depth + 1),
"string_b": self.string(depth + 1),
"delimiter": rng.choice(["", ", ", " "]),
},
}
elif choice == 2:
inputs = {
f"text_{letter}": self.string(depth + 1)
for letter in rng.sample("abcd", rng.randint(1, 3))
}
inputs["clean_whitespace"] = rng.choice(["true", "false", True])
node = {"class_type": "Text Concatenate", "inputs": inputs}
elif choice == 3:
node = {
"class_type": "StringConstantMultiline",
"inputs": {
"string": self.string(depth + 1),
"strip_newlines": rng.choice([True, False]),
},
}
elif choice == 4:
count = rng.randint(1, 4)
inputs = {
f"string_{index}": self.string(depth + 1)
for index in range(1, count + 1)
if index == 1 or rng.random() < 0.8
}
inputs.update(
{
"inputcount": count,
"delimiter": rng.choice([" ", ","]),
"return_list": rng.random() < 0.1,
}
)
node = {"class_type": "JoinStringMulti", "inputs": inputs}
elif choice == 5:
node = {
"class_type": "Reroute",
"inputs": {"value": self.string_link(depth + 1)},
}
elif choice == 6:
node = {
"class_type": rng.choice(["StringConstant", "TextInput"]),
"inputs": {rng.choice(["string", "text"]): self.string(depth + 1)},
}
else:
node = {
"class_type": "PromptManagerText",
"inputs": {
"text": self.string(depth + 1),
"prepend_text": self.word(),
"append_text": self.word(),
},
}
self.nodes[node_id] = node
return [node_id, 0]
def string_link(self, depth):
value = self.string(depth)
if isinstance(value, list):
return value
node_id = self.new_id()
self.nodes[node_id] = {
"class_type": "PrimitiveString",
"inputs": {"value": value},
}
return [node_id, 0]
def conditioning(self, depth):
rng = self.rng
node_id = self.new_id()
choice = rng.randrange(11) if depth < 4 else 0
slot = 0
if choice == 0:
node = {
"class_type": "CLIPTextEncode",
"inputs": {"text": self.string(depth + 1), "clip": ["4", 1]},
}
elif choice == 1:
text = self.string(depth + 1)
node = {
"class_type": "CLIPTextEncodeSDXL",
"inputs": {
"text_g": text,
"text_l": (
text if rng.random() < 0.6 else rng.choice(["", self.word()])
),
},
}
elif choice == 2:
node = {
"class_type": "FluxGuidance",
"inputs": {"conditioning": self.conditioning(depth + 1)},
}
elif choice == 3:
node = {
"class_type": "ConditioningZeroOut",
"inputs": {"conditioning": self.conditioning(depth + 1)},
}
elif choice == 4:
first = self.conditioning(depth + 1)
node = {
"class_type": "ConditioningCombine",
"inputs": {
"conditioning_1": first,
"conditioning_2": (
first if rng.random() < 0.5 else self.conditioning(depth + 1)
),
},
}
elif choice == 5:
condition = rng.choice([True, False, ["4", 0], ["4", 1]])
node = {
"class_type": "easy ifElse",
"inputs": {
"boolean": condition,
"on_true": self.conditioning(depth + 1),
"on_false": self.conditioning(depth + 1),
},
}
elif choice == 6:
node = {
"class_type": rng.choice(["PromptManager", "PromptManagerFocused"]),
"inputs": {
"text": self.string(depth + 1),
"prepend_text": self.word(),
"append_text": self.word(),
"clip": ["4", 1],
},
}
elif choice == 7:
node = {
"class_type": rng.choice(["PromptManagerV2", "PromptManagerPositive"]),
"inputs": {
"text": self.string(depth + 1),
"prepend_text": self.word(),
"negative_text": self.word(),
},
}
elif choice == 8:
slot = rng.choice([0, 1])
texts = {
"text": self.word(),
"negative_text": self.word(),
"prepend_text": self.word(),
"append_text": self.word(),
}
node = {"class_type": "PromptManagerPair", "inputs": dict(texts)}
if rng.random() < 0.6:
record = {
**texts,
"positive_text": self.word(),
"negative_effective_text": texts["negative_text"],
}
record["prepend_text"], record["append_text"] = (
record["prepend_text"].strip(),
record["append_text"].strip(),
)
if rng.random() < 0.2:
record["text"] = record["text"] + "!"
self.records[EXECUTION_KEY] = json.dumps(
{"version": 1, "nodes": {node_id: {"records": [record]}}}
)
elif choice == 9:
node = {
"class_type": "Reroute",
"inputs": {"value": self.conditioning(depth + 1)},
}
else:
node = {
"class_type": rng.choice(["PromptSearchList", "FancyEncoder"]),
"inputs": {"text": self.word()},
}
self.nodes[node_id] = node
return [node_id, slot]
def corrupt(self):
rng = self.rng
node_id = rng.choice(sorted(self.nodes))
node = self.nodes[node_id]
links = [
key
for key, value in node.get("inputs", {}).items()
if isinstance(value, list)
]
choice = rng.randrange(6)
if choice == 0 and links:
node["inputs"][rng.choice(links)][1] = rng.choice([0.0, True, 7, -1])
elif choice == 1 and links:
node["inputs"][rng.choice(links)][0] = rng.choice(["missing", node_id])
elif choice == 2:
node["class_type"] = rng.choice([None, 3, "Unknown"])
elif choice == 3:
node["inputs"] = rng.choice([[], {}])
elif choice == 4 and links:
del node["inputs"][rng.choice(links)]
else:
node.pop("inputs", None)
EXECUTION_KEY = "comfyassets_prompt_pairs"
def fuzz_cases(seed, count):
rng = random.Random(seed)
cases = {}
for index in range(count):
builder = _Builder(rng)
positive = builder.conditioning(0)
negative = builder.conditioning(0) if rng.random() < 0.9 else None
graph = _graph(positive, negative, builder.nodes)
if negative is None:
del graph["3"]["inputs"]["negative"]
if rng.random() < 0.25:
builder.nodes = graph
builder.corrupt()
records = {"prompt": json.dumps(graph, ensure_ascii=rng.random() < 0.5)}
records.update(builder.records)
cases[f"fuzz-{seed}-{index}"] = records
return cases
def single_chain(records):
"""Whether the server answers ``records`` without choosing among samplers."""
roles = {key for key in records if key in ("prompt", "parameters", EXECUTION_KEY)}
if "prompt" not in roles:
return "parameters" not in roles
try:
graph = json.loads(records["prompt"])
except (ValueError, RecursionError):
return True
if (
not isinstance(graph, dict)
or not graph
or len(graph) > INSPECTION_PROFILE.node_ceiling
):
return True
samplers = get_semantics().samplers
found = [
node["class_type"]
for node in graph.values()
if isinstance(node, dict)
and isinstance(node.get("class_type"), str)
and node["class_type"] in samplers
and samplers[node["class_type"]]["strategy"]
in ("ksampler", "custom-advanced", "detailer-stage")
]
return len(found) <= 1 and not any(
samplers[name]["strategy"] == "detailer-stage" for name in found
)
def _utf8(records):
"""Whether the record texts can be written as UTF-8 (no lone surrogate)."""
try:
json.dumps(records, ensure_ascii=False).encode("utf-8")
except UnicodeEncodeError:
return False
return True
def committed_vectors():
"""The committed record sets with the server's evidence, in a stable order."""
try:
from tests import local_resolver_selection_cases as selection
except ImportError: # pragma: no cover - discover -s tests puts tests/ first
import local_resolver_selection_cases as selection
named = {**golden_cases(), **selection.selection_cases()}
cases = {
name: records
for name, records in named.items()
if len(json.dumps(records)) <= MAX_VECTOR_CHARS and _utf8(records)
}
cases.update(fuzz_cases(*COMMITTED_FUZZ))
cases.update(selection.selection_fuzz_cases(*COMMITTED_FUZZ))
cases.update(selection.passthrough_fuzz_cases(*COMMITTED_PASSTHROUGH))
vectors = [
{
"name": name,
"records": records,
"evidence": inspect_prompt_evidence(
{key: value for key, value in records.items() if key != FILE_NAME},
None,
profile=INSPECTION_PROFILE,
file_name=records.get(FILE_NAME),
),
}
for name, records in cases.items()
]
return {"version": 2, "vectors": vectors}
def main():
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--write", action="store_true", help="rewrite the vectors")
if parser.parse_args().write:
text = json.dumps(committed_vectors(), ensure_ascii=False, sort_keys=True)
VECTORS_PATH.write_text(text + "\n", encoding="utf-8", newline="\n")
if __name__ == "__main__":
main()