feat(inspect): port legacy parser rules the new resolver missed (installed nodes)

R6 and R9 for nodes whose sources are installed beside ComfyUI, each
entry's input names and output slots read from the node's code (the task
report lists file and line):

- detailer stages UltimateSDUpscale (image), UltimateSDUpscaleNoUpscale
  (upscaled_image) and DetailerForEach (image), judged with their upstream
  samplers like FaceDetailer;
- pass-throughs ControlNetInpaintingAliMamaApply and LTXVConditioning
  (positive 0, negative 1) and FluxKontextMultiReferenceLatentMethod;
- encoder Text to Conditioning, strings TextInput and DisplayText;
- facts model pass-throughs Lora Loader (LoraManager), Power Lora Loader
  (rgthree), ModelPatchTorchSettings, CFGNorm, PerturbedAttentionGuidance.

Impact Pack encodes a detailer's non-empty wildcard instead of its positive
input, so FaceDetailer and DetailerForEach now list it as a prompt
override: a literal wildcard is decided at run time and a linked one names
its source, instead of resolving to the positive input. The overwritten
PromptManager refusal (R1) still holds behind the new pass-throughs. The
default profile does not read the table.
This commit is contained in:
Vito Sansevero
2026-10-19 15:00:33 -07:00
parent baf788b863
commit 46aeff92ca
8 changed files with 364 additions and 9 deletions
+39
View File
@@ -57,3 +57,42 @@ test("R10: commas inside quoted settings values do not split them", () => {
// An unbalanced quote is not a quoted value: the historical split, as A1111 reads it.
assert.deepEqual(parameterFacts(`a cat\n${FOOTER}, Note: "open, Model: base`).model, ["known", "base"]);
});
function graphFacts(graph, resolution) {
const facts = extractGenerationFacts({
prompt_graph: { status: "parsed", value: graph },
workflow_graph: { status: "absent", value: null },
metadataStatus: "available",
resolution,
});
return Object.fromEntries(fields.map(field => [field, [facts[field].status, facts[field].value]]));
}
function modelGraph(between) {
return {
ckpt: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "base.safetensors" } },
hop: { class_type: between, inputs: { model: ["ckpt", 0], clip: ["ckpt", 1] } },
s: { class_type: "KSampler", inputs: { seed: 5, steps: 20, cfg: 7, sampler_name: "euler", scheduler: "normal", model: ["hop", 0] } },
};
}
test("R9: model pass-throughs verified in their sources lead to the loader", () => {
for (const name of ["Lora Loader (LoraManager)", "Power Lora Loader (rgthree)", "ModelPatchTorchSettings", "CFGNorm", "PerturbedAttentionGuidance"]) {
assert.deepEqual(graphFacts(modelGraph(name)).model, ["known", "base.safetensors"], name);
// Only output 0 is the model.
const other = modelGraph(name); other.s.inputs.model = ["hop", 1];
assert.deepEqual(graphFacts(other).model, ["unresolved", null], name);
}
assert.deepEqual(graphFacts(modelGraph("ModelMergeSDXL")).model, ["unresolved", null]);
});
test("R6: upscale and SEGS detailers supply stage facts like FaceDetailer", () => {
for (const [class_type, image] of [["UltimateSDUpscale", "image"], ["UltimateSDUpscaleNoUpscale", "upscaled_image"], ["DetailerForEach", "image"]]) {
const graph = modelGraph("CFGNorm");
graph.decode = { class_type: "VAEDecode", inputs: { samples: ["s", 0], vae: ["ckpt", 2] } };
graph.d = { class_type, inputs: { [image]: ["decode", 0], model: ["hop", 0], seed: 7, steps: 12, cfg: 7, sampler_name: "euler", scheduler: "normal" } };
assert.deepEqual(graphFacts(graph, { state: "resolved", sampler_id: "d" }).seed, ["known", "7"], class_type);
const merged = graphFacts(graph, { state: "ambiguous", sampler_id: "d" });
assert.deepEqual([merged.seed[0], merged.steps[0], merged.cfg], ["ambiguous", "ambiguous", ["known", "7"]], class_type);
}
});
+175
View File
@@ -450,5 +450,180 @@ class TestBatchIndexDuplicates(unittest.TestCase):
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",
)
if __name__ == "__main__":
unittest.main()
+75
View File
@@ -125,6 +125,7 @@ class SpecEntriesTest(unittest.TestCase):
},
)
# D19: FaceDetailer is a stage over its upstream image, ksampler-shaped facts.
# A non-empty wildcard replaces the detail prompt (Impact core.py:267).
self.assertEqual(
table.samplers["FaceDetailer"],
{
@@ -132,6 +133,7 @@ class SpecEntriesTest(unittest.TestCase):
"conditioning": pair,
"image_stage": "image",
"facts": "ksampler",
"prompt_overrides": ("wildcard",),
},
)
@@ -310,6 +312,55 @@ class SpecEntriesTest(unittest.TestCase):
for name in ("PromptManager", "PromptManagerPair", "PromptManagerText"):
self.assertNotIn(name, table.all_classes)
def test_legacy_parser_ports_are_listed_with_their_source_inputs(self):
# Task 2.7b (R6): input names and slots read from each node's source;
# the report lists the file and line for every one.
table = load_semantics()
pair = {"positive": "positive", "negative": "negative"}
for name, image in (
("DetailerForEach", "image"),
("UltimateSDUpscale", "image"),
("UltimateSDUpscaleNoUpscale", "upscaled_image"),
):
entry = dict(table.samplers[name])
self.assertEqual(
entry.pop("prompt_overrides", None) is not None,
name == "DetailerForEach",
name,
)
self.assertEqual(
entry,
{
"strategy": "detailer-stage",
"conditioning": pair,
"image_stage": image,
"facts": "ksampler",
},
name,
)
for (name, slot), source in {
("ControlNetInpaintingAliMamaApply", 0): "positive",
("ControlNetInpaintingAliMamaApply", 1): "negative",
("LTXVConditioning", 0): "positive",
("LTXVConditioning", 1): "negative",
("FluxKontextMultiReferenceLatentMethod", 0): "conditioning",
}.items():
self.assertEqual(
table.conditioning[(name, slot)],
{"strategy": "passthrough", "input": source},
)
self.assertNotIn(
("FluxKontextMultiReferenceLatentMethod", 1), table.conditioning
)
self.assertEqual(
table.encoders[("Text to Conditioning", 0)],
{"strategy": "single", "inputs": ("text",)},
)
for name in ("TextInput", "DisplayText"):
self.assertEqual(
table.strings[(name, 0)], {"strategy": "literal", "inputs": ("text",)}
)
def test_stage_inputs_are_the_one_source_for_both_stage_walks(self):
# Review I-2: Python's resolver and comfy-metadata.js read these lists here.
stage_inputs = load_semantics().stage_inputs
@@ -374,6 +425,12 @@ class SpecEntriesTest(unittest.TestCase):
"ModelSamplingFlux",
"ModelSamplingAuraFlow",
"ModelSamplingSD3",
# Task 2.7b (R9): model in, model out, verified in their sources.
"Lora Loader (LoraManager)",
"Power Lora Loader (rgthree)",
"ModelPatchTorchSettings",
"CFGNorm",
"PerturbedAttentionGuidance",
),
)
self.assertEqual(
@@ -654,5 +711,23 @@ def _depth(value, level=1):
return max([level] + [_depth(item, level + 1) for item in children])
class SamplerOptionsTest(_TableCase):
"""Task 2.7b: optional sampler lists are input names, or the table fails."""
def test_prompt_overrides_are_input_names(self):
doc = _minimal()
doc["samplers"]["KSampler"]["prompt_overrides"] = ["wildcard"]
sampler = self.load_doc(doc).samplers["KSampler"]
self.assertEqual(sampler["prompt_overrides"], ("wildcard",))
for value in ([], "wildcard", [""], ["a", "a"], [1]):
with self.subTest(value=value):
doc["samplers"]["KSampler"]["prompt_overrides"] = value
with self.assertRaises(SemanticsError):
self.load_doc(doc)
doc["samplers"]["KSampler"].pop("prompt_overrides")
doc["samplers"]["KSampler"]["overrides"] = ["wildcard"]
self.assertRejected(doc, "unknown field")
if __name__ == "__main__":
unittest.main()
+8 -4
View File
@@ -2826,7 +2826,9 @@ class TestLatentAncestry(unittest.TestCase):
{
"50": {"class_type": "LoadImage", "inputs": {"image": "a.png"}},
"60": {
"class_type": "UltimateSDUpscale",
# Task 2.7b lists UltimateSDUpscale; an unlisted upscaler
# of the same shape keeps this rule's coverage.
"class_type": "MysteryUpscaler",
"inputs": {
"image": ["50", 0],
"model": ["4", 0],
@@ -2838,9 +2840,11 @@ class TestLatentAncestry(unittest.TestCase):
)
result = _inspect(source, output_node_id="9")
self.assertEqual(result["reason_code"], "unsupported-node")
self.assertEqual(
result["node"], {"id": "60", "class_type": "UltimateSDUpscale"}
)
self.assertEqual(result["node"], {"id": "60", "class_type": "MysteryUpscaler"})
# The listed stage is now an earlier sampler stage in the ancestry.
source["60"]["class_type"] = "UltimateSDUpscale"
result = _inspect(source, output_node_id="9")
self.assertEqual(result["reason"], resolver.MULTISTAGE_UNATTRIBUTED_REASON)
def test_latent_walk_budget_exhaustion_is_work_limit(self):
# 2,100 distinct images in the latent ancestry: more than one
+6 -1
View File
@@ -54,6 +54,7 @@ SAVER_PATTERNS = MappingProxyType(
"vhs-counter": "{name}_{counter:05}.{ext}",
}
)
_SAMPLER_OPTIONS = frozenset({"prompt_overrides"})
_CONFIGURATION_KINDS = frozenset({"noise", "sampler", "sigmas", "guider"})
_SCALAR_KINDS = frozenset({"number", "string"})
_TYPE_FIELDS = frozenset({"type_fields"})
@@ -177,9 +178,13 @@ def _check_sampler(entry: dict, strategy: str, where: str) -> None:
entry,
where,
frozenset({"strategy", "conditioning", stage, "facts"}),
frozenset(),
_SAMPLER_OPTIONS,
)
_name(entry[stage], where)
# Inputs that, when set, replace the prompt the sampler's conditioning
# inputs carry (a detailer's wildcard); the prompt is then not read.
if "prompt_overrides" in entry:
_names(entry["prompt_overrides"], where + ".prompt_overrides")
keys = {"guider"} if strategy == "custom-advanced" else {"positive", "negative"}
conditioning = entry["conditioning"]
_fields(conditioning, where + ".conditioning", frozenset(keys), frozenset())
+16
View File
@@ -1241,6 +1241,7 @@ class _GraphResolver:
try:
name, inputs = self.node(sampler_id)
entry = self.sampler_entry(name)
self.check_overrides(sampler_id, entry, inputs)
# A second sampler in the latent ancestry denotes a multistage run;
# associating the final image with one prompt pair would be a guess.
latent = entry.get("latent")
@@ -1284,6 +1285,21 @@ class _GraphResolver:
node=error.node,
)
def check_overrides(self, sampler_id: str, entry: Mapping, inputs: dict) -> None:
"""A set prompt override replaces the conditioning inputs' prompt.
Exact ``""`` (or a missing input) leaves the prompt alone. A literal is
processed at run time (a detailer wildcard); a link is a source the
inspector does not follow, so it is named.
"""
for name in entry.get("prompt_overrides", ()):
value = inputs.get(name)
if value is None or value == "":
continue
if _link(value):
raise self.unsupported("Unsupported prompt override", str(value[0]))
raise self.named(_RUNTIME_REASON, "runtime-value", sampler_id)
def latent_source(self, value: Any, seen: set, depth: int) -> None:
if not _link(value) or depth >= self.max_depth:
code = "malformed" if not _link(value) else "depth-limit"
+10 -2
View File
@@ -414,6 +414,9 @@
SamplerCustom: { facts: "custom" },
SamplerCustomAdvanced: { facts: "custom-advanced" },
FaceDetailer: { facts: "ksampler", image_stage: "image" },
DetailerForEach: { facts: "ksampler", image_stage: "image" },
UltimateSDUpscale: { facts: "ksampler", image_stage: "image" },
UltimateSDUpscaleNoUpscale: { facts: "ksampler", image_stage: "upscaled_image" },
});
// Stages merged for one result: the resolver's candidate cap. Together with
// MAX_VISITS per candidate walk these are deliberately tighter than the server's
@@ -431,7 +434,9 @@
"CLIPTextEncode", "CLIPTextEncodeSDXL", "CLIPTextEncodeFlux", "CLIPTextEncodeSD3", "TextEncodeQwenImageEdit",
"TextEncodeQwenImageEditPlus", "TextEncodeQwenImage21", "KikoTextEncode", "StringConstant", "StringConstantMultiline",
"JoinStringMulti", "PromptSearchList", "PreviewImage", "PreviewAny", "SaveImageWebsocket",
"Image Comparer (rgthree)",
"Image Comparer (rgthree)", "DetailerForEach", "UltimateSDUpscale", "UltimateSDUpscaleNoUpscale",
"ControlNetInpaintingAliMamaApply", "LTXVConditioning", "FluxKontextMultiReferenceLatentMethod", "Text to Conditioning",
"TextInput", "DisplayText",
]);
// The table's `stage_inputs` (the resolver reads the same lists): inputs that make an
// unlisted node a possible stage, and those that do so beside a linked `model`.
@@ -448,7 +453,10 @@
CheckpointLoader: "ckpt_name", CheckpointLoaderSimple: "ckpt_name",
unCLIPCheckpointLoader: "ckpt_name", UNETLoader: "unet_name",
});
const MODEL_PASSTHROUGHS = new Set(["LoraLoader", "LoraLoaderModelOnly", "ModelSamplingFlux", "ModelSamplingAuraFlow", "ModelSamplingSD3"]);
const MODEL_PASSTHROUGHS = new Set([
"LoraLoader", "LoraLoaderModelOnly", "ModelSamplingFlux", "ModelSamplingAuraFlow", "ModelSamplingSD3",
"Lora Loader (LoraManager)", "Power Lora Loader (rgthree)", "ModelPatchTorchSettings", "CFGNorm", "PerturbedAttentionGuidance",
]);
const CONFIGURATION_OUTPUTS = Object.freeze({
RandomNoise: { kind: "noise", fields: ["noise_seed"] },
KSamplerSelect: { kind: "sampler", fields: ["sampler_name"] },
+35 -2
View File
@@ -29,6 +29,26 @@
"strategy": "detailer-stage",
"conditioning": { "positive": "positive", "negative": "negative" },
"image_stage": "image",
"facts": "ksampler",
"prompt_overrides": ["wildcard"]
},
"DetailerForEach": {
"strategy": "detailer-stage",
"conditioning": { "positive": "positive", "negative": "negative" },
"image_stage": "image",
"facts": "ksampler",
"prompt_overrides": ["wildcard"]
},
"UltimateSDUpscale": {
"strategy": "detailer-stage",
"conditioning": { "positive": "positive", "negative": "negative" },
"image_stage": "image",
"facts": "ksampler"
},
"UltimateSDUpscaleNoUpscale": {
"strategy": "detailer-stage",
"conditioning": { "positive": "positive", "negative": "negative" },
"image_stage": "upscaled_image",
"facts": "ksampler"
}
},
@@ -57,6 +77,11 @@
{ "class": "InpaintModelConditioning", "slot": 1, "strategy": "passthrough", "input": "negative" },
{ "class": "WanImageToVideo", "slot": 0, "strategy": "passthrough", "input": "positive" },
{ "class": "WanImageToVideo", "slot": 1, "strategy": "passthrough", "input": "negative" },
{ "class": "ControlNetInpaintingAliMamaApply", "slot": 0, "strategy": "passthrough", "input": "positive" },
{ "class": "ControlNetInpaintingAliMamaApply", "slot": 1, "strategy": "passthrough", "input": "negative" },
{ "class": "LTXVConditioning", "slot": 0, "strategy": "passthrough", "input": "positive" },
{ "class": "LTXVConditioning", "slot": 1, "strategy": "passthrough", "input": "negative" },
{ "class": "FluxKontextMultiReferenceLatentMethod", "slot": 0, "strategy": "passthrough", "input": "conditioning" },
{ "class": "ConditioningZeroOut", "slot": 0, "strategy": "zeroed", "input": "conditioning" }
],
"selectors": {
@@ -80,10 +105,13 @@
{ "class": "TextEncodeQwenImageEditPlus", "slot": 0, "strategy": "single", "inputs": ["prompt"] },
{ "class": "TextEncodeQwenImage21", "slot": 0, "strategy": "single", "inputs": ["prompt"] },
{ "class": "TextEncodeQwenImage21", "slot": 1, "strategy": "single", "inputs": ["negative_prompt"] },
{ "class": "KikoTextEncode", "slot": 0, "strategy": "single", "inputs": ["text"] }
{ "class": "KikoTextEncode", "slot": 0, "strategy": "single", "inputs": ["text"] },
{ "class": "Text to Conditioning", "slot": 0, "strategy": "single", "inputs": ["text"] }
],
"strings": [
{ "class": "StringConstant", "slot": 0, "strategy": "literal", "inputs": ["string"] },
{ "class": "TextInput", "slot": 0, "strategy": "literal", "inputs": ["text"] },
{ "class": "DisplayText", "slot": 0, "strategy": "literal", "inputs": ["text"] },
{
"class": "StringConstantMultiline",
"slot": 0,
@@ -181,7 +209,12 @@
"LoraLoaderModelOnly",
"ModelSamplingFlux",
"ModelSamplingAuraFlow",
"ModelSamplingSD3"
"ModelSamplingSD3",
"Lora Loader (LoraManager)",
"Power Lora Loader (rgthree)",
"ModelPatchTorchSettings",
"CFGNorm",
"PerturbedAttentionGuidance"
],
"scalar_outputs": {
"PrimitiveInt": "number",