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

217 lines
7.6 KiB
Python

"""Synthetic graphs for saver anchoring and shared-conditioning stages (#381).
``branch_bench`` reproduces the maintainer's ``gpu_unlock_bench`` run: four
KSampler branches with different prompts, each saved by its own SaveImage whose
literal prefix names the branch (``bench/run/s45_branch_N``). ``shared_prefix``
is the dataset run's shape: every branch's saver uses one prefix, so the file
name cannot name the branch. ``two_stage_same_conditioning`` reproduces the
ANIMA run: a hires stage whose latent passes an unlisted node and reads the
same positive and negative links as the first stage. Texts are neutral.
"""
BRANCH_TEXTS = (
"a knit beanie on a wooden table",
"an old fishing boat at dawn",
"a red vintage car in the rain",
"a cozy library with tall shelves",
)
NEGATIVE = "blurry, lowres"
def _encode(text):
return {"class_type": "CLIPTextEncode", "inputs": {"text": text, "clip": ["1", 1]}}
def _ksampler(positive, negative, latent, denoise=1.0):
return {
"class_type": "KSampler",
"inputs": {
"seed": 1,
"steps": 20,
"cfg": 7.0,
"sampler_name": "euler",
"scheduler": "normal",
"denoise": denoise,
"model": ["1", 0],
"positive": positive,
"negative": negative,
"latent_image": latent,
},
}
def _base():
return {
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "m"}},
"2": {"class_type": "EmptyLatentImage", "inputs": {"width": 64}},
"3": _encode(NEGATIVE),
}
def branch_bench(prefixes=None, saver="SaveImage"):
"""Four branches; ``prefixes`` replaces each saver's ``filename_prefix``."""
prefixes = prefixes or [
f"bench/run/s45_branch_{index}" for index in range(1, len(BRANCH_TEXTS) + 1)
]
graph = _base()
for index, text in enumerate(BRANCH_TEXTS, start=1):
base = index * 10
graph[str(base)] = _encode(text)
graph[str(base + 1)] = _ksampler([str(base), 0], ["3", 0], ["2", 0])
graph[str(base + 2)] = {
"class_type": "VAEDecode",
"inputs": {"samples": [str(base + 1), 0], "vae": ["1", 2]},
}
graph[str(base + 3)] = {
"class_type": saver,
"inputs": {
"filename_prefix": prefixes[index - 1],
"images": [str(base + 2), 0],
},
}
return graph
def linked_prefix_bench():
"""The branch run with each prefix coming from a PrimitiveString."""
graph = branch_bench(prefixes=[["90", 0], ["91", 0], ["92", 0], ["93", 0]])
for index in range(4):
graph[str(90 + index)] = {
"class_type": "PrimitiveString",
"inputs": {"value": f"bench/run/s45_branch_{index + 1}"},
}
return graph
def shared_prefix():
"""Every branch saved with one prefix: the file name names no branch."""
return branch_bench(prefixes=["dataset/IMG_GEN"] * len(BRANCH_TEXTS))
def two_stage_same_conditioning(second_positive=None):
"""Base and hires stage on the same links; the hires latent passes an
unlisted node (``VendorGpuCleaner``), so the stages' lineage is unknown."""
graph = _base()
graph["90"] = _encode(BRANCH_TEXTS[0])
graph["63"] = _ksampler(["90", 0], ["3", 0], ["2", 0])
graph["30"] = {
"class_type": "VAEDecode",
"inputs": {"samples": ["63", 0], "vae": ["1", 2]},
}
graph["98"] = {
"class_type": "SaveImage",
"inputs": {"filename_prefix": "draft", "images": ["30", 0]},
}
graph["110"] = {"class_type": "VendorGpuCleaner", "inputs": {"anything": ["63", 0]}}
graph["108"] = {
"class_type": "LatentUpscaleBy",
"inputs": {
"upscale_method": "nearest-exact",
"scale_by": 1.2,
"samples": ["110", 0],
},
}
graph["114"] = _ksampler(second_positive or ["90", 0], ["3", 0], ["108", 0], 0.5)
graph["113"] = {
"class_type": "VAEDecode",
"inputs": {"samples": ["114", 0], "vae": ["1", 2]},
}
graph["115"] = {
"class_type": "VendorGpuCleaner",
"inputs": {"anything": ["113", 0]},
}
graph["107"] = {
"class_type": "SaveImage",
"inputs": {"filename_prefix": "final", "images": ["115", 0]},
}
if second_positive is not None:
graph["91"] = _encode(BRANCH_TEXTS[1])
return graph
def hidden_earlier_prompt(override=False):
"""Review shape: the skipped same-links stage continues a different prompt.
10 (cat) -> 11 (dog) -> unlisted LatentBlend -> 13 (dog, same links as 11)
-> VAEDecode -> SaveImage ``out``. With ``override``, 11 is a
``KSampler (Efficient)`` with a linked ``script``.
"""
graph = _base()
graph["5"] = _encode("cat X")
graph["6"] = _encode("dog Y")
graph["10"] = _ksampler(["5", 0], ["3", 0], ["2", 0])
graph["11"] = _ksampler(["6", 0], ["3", 0], ["10", 0])
if override:
graph["11"] = _ksampler(["6", 0], ["3", 0], ["2", 0])
graph["11"]["class_type"] = "KSampler (Efficient)"
graph["11"]["inputs"]["script"] = ["40", 0]
graph["40"] = {"class_type": "XY Plot", "inputs": {"grid_spacing": 0}}
graph["12"] = {
"class_type": "LatentBlend",
"inputs": {"samples1": ["11", 0], "samples2": ["2", 0], "blend_factor": 0.5},
}
graph["13"] = _ksampler(["6", 0], ["3", 0], ["12", 0], 0.5)
graph["14"] = {
"class_type": "VAEDecode",
"inputs": {"samples": ["13", 0], "vae": ["1", 2]},
}
graph["15"] = {
"class_type": "SaveImage",
"inputs": {"filename_prefix": "out", "images": ["14", 0]},
}
return graph
def deep_prefixes(chain):
"""Review shape: two branches whose savers both trace the prefix ``dup``.
Saver A's prefix runs through ``chain`` DisplayText nodes; saver B's
through one more, so a short visit allowance can finish only one trace.
"""
graph = branch_bench(prefixes=[["p0", 0], ["q", 0], "x_1", "x_2"])
graph["pz"] = {"class_type": "StringConstant", "inputs": {"string": "dup"}}
previous = "pz"
for index in range(chain - 1, -1, -1):
graph[f"p{index}"] = {
"class_type": "DisplayText",
"inputs": {"text": [previous, 0]},
}
previous = f"p{index}"
graph["q"] = {"class_type": "DisplayText", "inputs": {"text": ["p0", 0]}}
return graph
def first_stage_variant(kind):
"""``two_stage_same_conditioning`` whose first stage (63) is not plain.
``no-latent`` drops its latent input (still plain); ``literal-latent``,
``missing-latent``, ``cyclic-latent``, ``stage-shaped`` and ``detailer``
each make it something other than a plain first stage, so the later
stage stays unattributed.
"""
graph = two_stage_same_conditioning()
inputs = graph["63"]["inputs"]
if kind == "no-latent":
del inputs["latent_image"]
elif kind == "literal-latent":
inputs["latent_image"] = "latent"
elif kind == "missing-latent":
inputs["latent_image"] = ["999", 0]
elif kind == "cyclic-latent":
graph["70"] = {"class_type": "VendorLatentOp", "inputs": {"samples": ["63", 0]}}
inputs["latent_image"] = ["70", 0]
elif kind == "stage-shaped":
graph["70"] = {
"class_type": "VendorSampler",
"inputs": {
"model": ["1", 0],
"positive": ["90", 0],
"latent_image": ["2", 0],
},
}
inputs["latent_image"] = ["70", 0]
elif kind == "detailer":
graph["63"]["class_type"] = "FaceDetailer"
inputs["image"] = ["30", 0]
return graph